Public/system/Clear-DiskCleanup.ps1

#Requires -Version 5.1
function Clear-DiskCleanup {
    <#
        .SYNOPSIS
            Removes temporary files and system cleanup targets from local or remote machines
 
        .DESCRIPTION
            Performs disk cleanup operations across multiple categories including temporary
            files, Windows Update cache, Recycle Bin, crash dumps, old logs, browser caches,
            Windows.old, and thumbnail caches. Supports remote execution via the private
            Invoke-RemoteOrLocal helper. Accepts pipeline input from Get-DiskCleanupInfo.
 
        .PARAMETER ComputerName
            One or more computer names to target. Defaults to the local computer.
            Accepts pipeline input by property name.
 
        .PARAMETER Category
            One or more cleanup categories to process. Valid values: TempFiles,
            WindowsUpdate, RecycleBin, CrashDumps, OldLogs, BrowserCache, WindowsOld,
            ThumbnailCache, All. Defaults to All.
 
        .PARAMETER OlderThanDays
            Only remove files older than this many days for TempFiles and OldLogs
            categories. Valid range 1-3650. Defaults to 30.
 
        .PARAMETER ExcludePath
            One or more file paths to exclude from cleanup. Files whose full path
            starts with any excluded path will be skipped.
 
        .PARAMETER Force
            Suppresses the confirmation prompt and forces deletion of read-only files.
 
        .PARAMETER Credential
            Optional PSCredential for authenticating to remote computers.
            Not used for local queries.
 
        .EXAMPLE
            Clear-DiskCleanup -Category 'TempFiles' -Force
 
            Cleans temporary files older than 30 days on the local computer.
 
        .EXAMPLE
            Clear-DiskCleanup -ComputerName 'SRV01' -Category 'WindowsUpdate', 'OldLogs' -Force
 
            Cleans WindowsUpdate cache and old log files on remote server SRV01.
 
        .EXAMPLE
            Get-DiskCleanupInfo -ComputerName 'SRV01' | Where-Object SizeMB -gt 100 | Clear-DiskCleanup -Force
 
            Pipes scan results and cleans only categories larger than 100 MB.
 
        .EXAMPLE
            Clear-DiskCleanup -Category 'All' -ExcludePath 'C:\Windows\Logs\CBS' -WhatIf
 
            Shows what would be cleaned across all categories, excluding a specific path.
 
        .OUTPUTS
            PSWinOps.DiskCleanupResult
            Returns one result object per category per computer with FilesRemoved,
            FilesSkipped, SpaceRecoveredBytes, SpaceRecoveredMB, and Errors.
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-04-10
            Requires: PowerShell 5.1+ / Windows only
            Requires: Administrator privileges for most categories
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/remove-item
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType('PSWinOps.DiskCleanupResult')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('TempFiles', 'WindowsUpdate', 'RecycleBin', 'CrashDumps', 'OldLogs', 'BrowserCache', 'WindowsOld', 'ThumbnailCache', 'All')]
        [string[]]$Category = 'All',

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 3650)]
        [int]$OlderThanDays = 30,

        [Parameter(Mandatory = $false)]
        [string[]]$ExcludePath,

        [Parameter(Mandatory = $false)]
        [switch]$Force,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting disk cleanup"

        $allCategories = @(
            'TempFiles', 'WindowsUpdate', 'RecycleBin', 'CrashDumps',
            'OldLogs', 'BrowserCache', 'WindowsOld', 'ThumbnailCache'
        )

        $resolvedCategories = if ($Category -contains 'All') { $allCategories } else { $Category }

        if ($Force.IsPresent -and -not $PSBoundParameters.ContainsKey('Confirm')) {
            $ConfirmPreference = 'None'
        }

        $cleanupBlock = {
            param(
                [string]$CategoryName,
                [int]$DaysOld,
                [string]$ExcludeJson,
                [bool]$ForceCleanup
            )

            $excludes = if ($ExcludeJson) { @(ConvertFrom-Json -InputObject $ExcludeJson) } else { @() }
            $cutoffDate = (Get-Date).AddDays(-$DaysOld)

            $state = @{
                FilesRemoved   = 0
                FilesSkipped   = 0
                SpaceRecovered = [long]0
                Errors         = [System.Collections.Generic.List[string]]::new()
            }

            # Common file deletion helper with exclusion check and error collection
            $processFileList = {
                param([object[]]$FileItems)
                foreach ($fileItem in $FileItems) {
                    $skipItem = $false
                    foreach ($exc in $excludes) {
                        if ($fileItem.FullName.StartsWith($exc, [System.StringComparison]::OrdinalIgnoreCase)) {
                            $skipItem = $true
                            break
                        }
                    }
                    if ($skipItem) {
                        $state.FilesSkipped++
                        continue
                    }
                    try {
                        $itemSize = [long]$fileItem.Length
                        Remove-Item -LiteralPath $fileItem.FullName -Force:$ForceCleanup -ErrorAction Stop
                        $state.FilesRemoved++
                        $state.SpaceRecovered += $itemSize
                    }
                    catch {
                        $state.FilesSkipped++
                        if ($state.Errors.Count -lt 10) {
                            $state.Errors.Add("$($fileItem.FullName): $($_.Exception.Message)")
                        }
                    }
                }
            }

            switch ($CategoryName) {

                'TempFiles' {
                    $tempPaths = @(
                        $env:TEMP
                        (Join-Path -Path $env:SystemRoot -ChildPath 'Temp')
                    )
                    foreach ($tempPath in $tempPaths) {
                        if (Test-Path -LiteralPath $tempPath) {
                            $files = @(
                                Get-ChildItem -LiteralPath $tempPath -Recurse -File -Force -ErrorAction SilentlyContinue |
                                    Where-Object -FilterScript { $_.LastWriteTime -lt $cutoffDate }
                            )
                            if ($files) { & $processFileList -FileItems $files }
                        }
                    }
                }

                'WindowsUpdate' {
                    $downloadPath = Join-Path -Path $env:SystemRoot -ChildPath 'SoftwareDistribution\Download'
                    $serviceWasStopped = $false
                    try {
                        Stop-Service -Name 'wuauserv' -Force -ErrorAction Stop
                        $serviceWasStopped = $true
                    }
                    catch {
                        if ($state.Errors.Count -lt 10) {
                            $state.Errors.Add("Failed to stop wuauserv: $($_.Exception.Message)")
                        }
                    }
                    if (Test-Path -LiteralPath $downloadPath) {
                        $files = @(Get-ChildItem -LiteralPath $downloadPath -Recurse -File -Force -ErrorAction SilentlyContinue)
                        if ($files) { & $processFileList -FileItems $files }
                    }
                    if ($serviceWasStopped) {
                        try {
                            Start-Service -Name 'wuauserv' -ErrorAction Stop
                        }
                        catch {
                            if ($state.Errors.Count -lt 10) {
                                $state.Errors.Add("Failed to start wuauserv: $($_.Exception.Message)")
                            }
                        }
                    }
                }

                'RecycleBin' {
                    try {
                        Clear-RecycleBin -Force -ErrorAction Stop
                        $state.FilesRemoved++
                    }
                    catch {
                        # Fallback: manual removal
                        $recyclePath = Join-Path -Path $env:SystemDrive -ChildPath '$Recycle.Bin'
                        if (Test-Path -LiteralPath $recyclePath) {
                            $files = @(
                                Get-ChildItem -LiteralPath $recyclePath -Recurse -File -Force -ErrorAction SilentlyContinue
                            )
                            if ($files) { & $processFileList -FileItems $files }
                        }
                    }
                }

                'CrashDumps' {
                    $dumpPaths = @(
                        (Join-Path -Path $env:SystemRoot -ChildPath 'Minidump')
                        (Join-Path -Path $env:SystemRoot -ChildPath 'LiveKernelReports')
                    )
                    foreach ($dumpPath in $dumpPaths) {
                        if (Test-Path -LiteralPath $dumpPath) {
                            $files = @(
                                Get-ChildItem -LiteralPath $dumpPath -Filter '*.dmp' -Recurse -File -Force -ErrorAction SilentlyContinue
                            )
                            if ($files) { & $processFileList -FileItems $files }
                        }
                    }
                    $memoryDmpPath = Join-Path -Path $env:SystemRoot -ChildPath 'MEMORY.DMP'
                    if (Test-Path -LiteralPath $memoryDmpPath) {
                        $memDmp = Get-Item -LiteralPath $memoryDmpPath -Force -ErrorAction SilentlyContinue
                        if ($memDmp) { & $processFileList -FileItems @($memDmp) }
                    }
                }

                'OldLogs' {
                    $logPaths = @(
                        (Join-Path -Path $env:SystemRoot -ChildPath 'Logs')
                        (Join-Path -Path $env:SystemRoot -ChildPath 'System32\LogFiles')
                    )
                    $inetpubLogs = 'C:\inetpub\logs'
                    if (Test-Path -LiteralPath $inetpubLogs) {
                        $logPaths += $inetpubLogs
                    }
                    foreach ($logPath in $logPaths) {
                        if (Test-Path -LiteralPath $logPath) {
                            $files = @(
                                Get-ChildItem -LiteralPath $logPath -Recurse -File -Force -ErrorAction SilentlyContinue |
                                    Where-Object -FilterScript {
                                        ($_.Extension -eq '.log' -or $_.Extension -eq '.etl') -and
                                        $_.LastWriteTime -lt $cutoffDate
                                    }
                            )
                            if ($files) { & $processFileList -FileItems $files }
                        }
                    }
                }

                'BrowserCache' {
                    $skipProfiles = @('Public', 'Default', 'Default User', 'All Users')
                    $cacheRelatives = @(
                        'AppData\Local\Google\Chrome\User Data\*\Cache\*'
                        'AppData\Local\Microsoft\Edge\User Data\*\Cache\*'
                        'AppData\Local\Mozilla\Firefox\Profiles\*\cache2\*'
                    )
                    $usersDir = Join-Path -Path $env:SystemDrive -ChildPath 'Users'
                    if (Test-Path -LiteralPath $usersDir) {
                        $userDirs = @(
                            Get-ChildItem -LiteralPath $usersDir -Directory -Force -ErrorAction SilentlyContinue |
                                Where-Object -FilterScript { $_.Name -notin $skipProfiles }
                        )
                        foreach ($userDir in $userDirs) {
                            foreach ($rel in $cacheRelatives) {
                                $fullGlob = Join-Path -Path $userDir.FullName -ChildPath $rel
                                $files = @(Get-ChildItem -Path $fullGlob -Recurse -File -Force -ErrorAction SilentlyContinue)
                                if ($files) { & $processFileList -FileItems $files }
                            }
                        }
                    }
                }

                'WindowsOld' {
                    $windowsOldPath = Join-Path -Path $env:SystemDrive -ChildPath 'Windows.old'
                    if (Test-Path -LiteralPath $windowsOldPath) {
                        if ($excludes.Count -eq 0) {
                            # Fast path: measure then remove entire directory
                            $files = @(Get-ChildItem -LiteralPath $windowsOldPath -Recurse -File -Force -ErrorAction SilentlyContinue)
                            $totalSize = [long]0
                            foreach ($f in $files) { $totalSize += $f.Length }
                            try {
                                Remove-Item -LiteralPath $windowsOldPath -Recurse -Force:$ForceCleanup -ErrorAction Stop
                                $state.FilesRemoved += $files.Count
                                $state.SpaceRecovered += $totalSize
                            }
                            catch {
                                if ($state.Errors.Count -lt 10) {
                                    $state.Errors.Add("Failed to remove Windows.old: $($_.Exception.Message)")
                                }
                            }
                        }
                        else {
                            # File-by-file to honour exclusions
                            $files = @(Get-ChildItem -LiteralPath $windowsOldPath -Recurse -File -Force -ErrorAction SilentlyContinue)
                            if ($files) { & $processFileList -FileItems $files }
                        }
                    }
                }

                'ThumbnailCache' {
                    $skipProfiles = @('Public', 'Default', 'Default User', 'All Users')
                    $usersDir = Join-Path -Path $env:SystemDrive -ChildPath 'Users'
                    if (Test-Path -LiteralPath $usersDir) {
                        $userDirs = @(
                            Get-ChildItem -LiteralPath $usersDir -Directory -Force -ErrorAction SilentlyContinue |
                                Where-Object -FilterScript { $_.Name -notin $skipProfiles }
                        )
                        foreach ($userDir in $userDirs) {
                            $explorerPath = Join-Path -Path $userDir.FullName -ChildPath 'AppData\Local\Microsoft\Windows\Explorer'
                            if (Test-Path -LiteralPath $explorerPath) {
                                $files = @(
                                    Get-ChildItem -LiteralPath $explorerPath -Filter 'thumbcache_*.db' -File -Force -ErrorAction SilentlyContinue
                                )
                                if ($files) { & $processFileList -FileItems $files }
                            }
                        }
                    }
                }
            }

            @{
                Category       = $CategoryName
                FilesRemoved   = $state.FilesRemoved
                FilesSkipped   = $state.FilesSkipped
                SpaceRecovered = $state.SpaceRecovered
                Errors         = @($state.Errors)
            }
        }
    }

    process {
        foreach ($machine in $ComputerName) {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing '$machine'"

            $excludeJson = if ($ExcludePath) {
                ConvertTo-Json -InputObject @($ExcludePath) -Compress
            }
            else {
                ''
            }

            foreach ($cat in $resolvedCategories) {
                if (-not $PSCmdlet.ShouldProcess("$machine --> $cat", "Remove $cat cleanup targets")) {
                    continue
                }

                try {
                    $invokeParams = @{
                        ComputerName = $machine
                        ScriptBlock  = $cleanupBlock
                        ArgumentList = @($cat, $OlderThanDays, $excludeJson, $Force.IsPresent)
                    }
                    if ($PSBoundParameters.ContainsKey('Credential')) {
                        $invokeParams['Credential'] = $Credential
                    }

                    $raw = Invoke-RemoteOrLocal @invokeParams

                    [PSCustomObject]@{
                        PSTypeName          = 'PSWinOps.DiskCleanupResult'
                        ComputerName        = $machine
                        Category            = $raw.Category
                        FilesRemoved        = [int]$raw.FilesRemoved
                        FilesSkipped        = [int]$raw.FilesSkipped
                        SpaceRecoveredBytes = [long]$raw.SpaceRecovered
                        SpaceRecoveredMB    = [math]::Round($raw.SpaceRecovered / 1MB, 2)
                        Errors              = $raw.Errors
                        Timestamp           = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                    }
                }
                catch {
                    Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${machine}': $_"
                    continue
                }
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed"
    }
}