Skip to content

No log file? #1

Description

@PCAssistSoftware

Output says "Log: C:\Temp\PatchCleanerPS.log" - but it never creates this log

Amended version below with working logging:

# PatchCleanerPS — Interactive & Auto Modes
# Version       : 1.0.0
# Author        : jackharvest
# License       : MIT
# Last Updated  : 2025-07-07
# =============================================================================
# PowerShell successor to PatchCleaner. Scans **C:\Windows\Installer** for
# orphaned .MSI / .MSP files, then deletes or quarantines them. Empty
# sub-folders are pruned afterwards.

param(
    [string[]] $ExcludeVendors = @(),
    [switch]   $Auto        = $false,
    [switch]   $AutoAll     = $false,
    [switch]   $AutoDry     = $false,
    [switch]   $AutoDryAll  = $false
)

# ---------------------------------------------------------------------------
# Update-check: Ensure this exe is the latest release (skip when running .ps1)
# ---------------------------------------------------------------------------
function Check-LatestVersion {
    param([string]$CurrentVersion)
    try {
        $apiUrl = 'https://api.github.com/repos/jackharvest/PatchCleanerPS/releases/latest'
        $resp   = Invoke-RestMethod -Uri $apiUrl -Headers @{ 'User-Agent' = 'PatchCleanerPS' } -ErrorAction Stop
        $latest = $resp.tag_name.TrimStart('v','V')
        if ([version]$CurrentVersion -lt [version]$latest) {
            Write-Warning "PatchCleanerPS v$latest is available (you have v$CurrentVersion)."
			Write-Warning "Download it here: https://github.com/jackharvest/PatchCleanerPS/releases"		
            exit 1
        }
    } catch {
		Write-Verbose "Version check failed: $_"
	}
}

# Only run update-check when packaged as .exe, dynamically get version from exe																		 
if ($MyInvocation.MyCommand.Path -match '\.exe$') {
    $scriptPath     = $MyInvocation.MyCommand.Path
    $currentVersion = (Get-Item $scriptPath).VersionInfo.FileVersion
    Check-LatestVersion -CurrentVersion $currentVersion
}

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
function Format-Size([long]$bytes) {
    if ($bytes -lt 1MB)   { "{0:N2} KB" -f ($bytes/1KB) }
    elseif ($bytes -lt 1GB){ "{0:N2} MB" -f ($bytes/1MB) }
    else                   { "{0:N2} GB" -f ($bytes/1GB) }
}

function Build-KeepSet {
    $keep = @{}; function AddK($n){ $keep[$n.ToLower()] = $true }
    $wi   = New-Object -ComObject WindowsInstaller.Installer
    foreach ($prod in $wi.Products()) {
        if ($wi.ProductState($prod) -eq 5) {
            AddK (Split-Path ($wi.ProductInfo($prod,'LocalPackage')) -Leaf)
        }
    }
    $ud = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData'
    if (Test-Path $ud) {
        Get-ChildItem $ud | ForEach-Object {
            $patchKey = Join-Path $_.PsPath 'Patches'
            if (Test-Path $patchKey) {
                Get-ChildItem $patchKey | ForEach-Object {
                    $p = Get-ItemProperty $_.PsPath -EA SilentlyContinue
                    if ($p.LocalPackage) { AddK (Split-Path $p.LocalPackage -Leaf) }
                }
            }
            $prodKey = Join-Path $_.PsPath 'Products'
            if (Test-Path $prodKey) {
                Get-ChildItem $prodKey | ForEach-Object {
                    $ip = Join-Path $_.PsPath 'InstallProperties'
                    if (Test-Path $ip) {
                        $iprop = Get-ItemProperty $ip -EA SilentlyContinue
                        if ($iprop.LocalPackage) { AddK (Split-Path $iprop.LocalPackage -Leaf) }
                    }
                }
            }
        }
    }
    return $keep
}

function Is-Excluded($file) {
    if (-not $ExcludeVendors) { return $false }
    foreach ($v in $ExcludeVendors) {
        if ($file.Name -match $v) { return $true }
        try {
            $sig = Get-AuthenticodeSignature $file.FullName
            if ($sig.SignerCertificate -and $sig.SignerCertificate.Subject -match $v) {
                return $true
            }
        } catch {}
    }
    return $false
}

# ---------------------------------------------------------------------------
# Core scan + action logic
# ---------------------------------------------------------------------------
function Run-Cleanup {
    param([bool]$Interactive)

    # Determine run mode flags
    $autoFlags = @($Auto, $AutoAll, $AutoDry, $AutoDryAll) | Where-Object { $_ }
    if ($autoFlags.Count -gt 1) {
        throw 'Use only ONE of -Auto, -AutoAll, -AutoDry, -AutoDryAll.'
    }
    $MoveMode = $false
    $WhatIf   = $true
    switch ($true) {
        $Auto       { $WhatIf = $false;  if (-not $ExcludeVendors) { $ExcludeVendors = @('Acrobat') } }
        $AutoAll    { $WhatIf = $false }
        $AutoDry    { $WhatIf = $true;   if (-not $ExcludeVendors) { $ExcludeVendors = @('Acrobat') } }
        $AutoDryAll { $WhatIf = $true }
    }

    if ($Interactive) {
        Clear-Host
		# Warn about admin rights only if not elevated
        $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
        if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
            Write-Host 'NOTE: Admin rights required for Move/Delete modes.' -ForegroundColor Yellow
        }

        # Banner & mode selection											  
        $banner = @'
 _____      _       _      _____ _                            _____   _____ 
|  __ \    | |     | |    / ____| |                          |  __ \ / ____|
| |__) |_ _| |_ ___| |__ | |    | | ___  __ _ _ __   ___ _ __| |__) | (___  
|  ___/ _` | __/ __| '_ \| |    | |/ _ \/ _` | '_ \ / _ \ '__|  ___/ \___ \ 
| |  | (_| | || (__| | | | |____| |  __/ (_| | | | |  __/ |  | |     ____) |
|_|   \__,_|\__\___|_| |_|\_____|_|\___|\__,_|_| |_|\___|_|  |_|    |_____/ 
'@
        Write-Host $banner -ForegroundColor DarkYellow
        Write-Host ' 1) Dry-Run (no changes)'
        Write-Host ' 2) Move-Run (quarantine)'
        Write-Host ' 3) Delete-Run'
        $sel = Read-Host 'Enter 1, 2, or 3 [default 1]'
        if (-not $sel) { $sel = '1' }
        switch ($sel) {
            '2' {
                $WhatIf = $false
                $MoveMode = $true
                $QuarDir = 'C:\InstallerQuarantine'
                if (-not (Test-Path $QuarDir)) {
                    New-Item $QuarDir -ItemType Directory -Force | Out-Null
                }
            }
            '3' { $WhatIf = $false }
            default { $WhatIf = $true }
        }

		# Elevation check for live runs							   
        if (-not $WhatIf) {
            $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
            if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
                 Write-Host 'ERROR: run PowerShell **as Administrator** for live actions.' -ForegroundColor Red
                exit 1
            }
        }

       # Vendor filter prompt
        if (-not $ExcludeVendors) { $ExcludeVendors = @('Acrobat') }
        Write-Host "Current vendor filter list: $($ExcludeVendors -join ', ')" -ForegroundColor Cyan
        $add = Read-Host 'Add vendors (comma-separated) or press Enter'
        if ($add) {
            $ExcludeVendors += ($add -split ',').Trim()
            $ExcludeVendors = $ExcludeVendors | Select-Object -Unique
        }
    }

    # Paths & logger
    $logDir = 'C:\Temp'
    if (-not (Test-Path $logDir)) {
        New-Item $logDir -ItemType Directory -Force | Out-Null
    }
    $logFile = Join-Path $logDir 'PatchCleanerPS.log'
    function Log($msg) {
      (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "`t$msg" |
            Out-File -FilePath $logFile -Append -Encoding utf8
    }
    $insDir = 'C:\Windows\Installer'

   
    # >>> ADDED: tracking lists
    
    $orphanedFiles = @()
    $excludedFiles = @()

    # Create restore point for live runs
    if (-not $WhatIf) {
        try {
            Checkpoint-Computer -Description 'PatchCleanerPS' -RestorePointType MODIFY_SETTINGS -ErrorAction Stop | Out-Null
			Write-Host 'System restore point created.' -ForegroundColor Green
            Log 'Restore point created.'
        } catch {
            Write-Host 'ERROR: failed to create restore point, aborting.' -ForegroundColor Red
            exit 1
        }
    }

    # Build keep set & scan files
    $keepSet = Build-KeepSet
    $files   = Get-ChildItem $insDir -Recurse -File -Force | Where-Object { $_.Extension -match '\.(msi|msp)$' }
	$total   = $files.Count					   

    [long]$usedBytes = 0; $usedCnt = 0
    [long]$excBytes  = 0; $excCnt  = 0
    [long]$orpBytes  = 0; $orpCnt  = 0

	$i = 0	  
    foreach ($f in $files) {
		$i++
        if ($Interactive) {
            Write-Progress -Activity 'Scanning installer files' -Status "$i / $total" -PercentComplete ([math]::Floor($i/$total*100))
        }
        $size = $f.Length; $key = $f.Name.ToLower()
        if ($keepSet[$key]) {
            $usedCnt++; $usedBytes += $size
            continue
        }
        if (Is-Excluded $f) {
            $excCnt++; $excBytes += $size
            $excludedFiles += $f.FullName   # >>> ADDED
            continue
        }
        # orphaned
        $orpCnt++; $orpBytes += $size
        $orphanedFiles += $f.FullName      # >>> ADDED
        if (-not $WhatIf) {
            if ($MoveMode) {
                Move-Item $f.FullName -Destination $QuarDir -Force -Confirm:$false
            } else {
                Remove-Item $f.FullName -Force -Confirm:$false
            }
        }
    }
																				if ($Interactive) { Write-Progress -Activity 'Scanning installer files' -Completed }

    # Remove empty directories
    Get-ChildItem $insDir -Recurse -Directory -Force -ErrorAction SilentlyContinue |
        Sort-Object FullName -Descending |
        Where-Object { (Get-ChildItem $_ -Force -ErrorAction SilentlyContinue).Count -eq 0 } |
        ForEach-Object {
            if (-not $WhatIf) {
                try { Remove-Item $_.FullName -Recurse -Force -Confirm:$false } catch {}
            }
        }		

    # LOG OUTPUT (>>> ADDED)

    Log "===== PatchCleanerPS RUN ====="

    Log ""
    Log "---- EXCLUDED FILES ----"
    if ($excludedFiles.Count -eq 0) { Log "None" }
    else { $excludedFiles | ForEach-Object { Log $_ } }

    Log ""
    Log "---- ORPHANED FILES ----"
    if ($orphanedFiles.Count -eq 0) { Log "None" }
    else { $orphanedFiles | ForEach-Object { Log $_ } }

    Log ""
    Log "===== END RUN ====="

    # Summary banner
    $usedStr = Format-Size $usedBytes
    $excStr  = Format-Size $excBytes
    $orpStr  = Format-Size $orpBytes
	$mode    = if ($WhatIf) { 'Completed (WhatIf)' } else { 'Completed' }

    Write-Host '=======================' -ForegroundColor Cyan -BackgroundColor DarkBlue
    Write-Host "=== $mode ===" -ForegroundColor Cyan -BackgroundColor DarkBlue
    Write-Host '=======================' -ForegroundColor Cyan -BackgroundColor DarkBlue
    Write-Host "=== $usedCnt files still used, $usedStr ===" -ForegroundColor Red
    Write-Host "=== $excCnt files excluded, $excStr ===" -ForegroundColor Yellow
    if ($WhatIf) {
        Write-Host "=== $orpCnt files orphaned, $orpStr ===" -ForegroundColor Green
    } elseif ($MoveMode) {
        Write-Host "=== $orpCnt files moved, $orpStr ===" -ForegroundColor Green
    } else {
        Write-Host "=== $orpCnt files deleted, $orpStr ===" -ForegroundColor Green
    }
    Write-Host "Log: $logFile"
}

# ---------------------------------------------------------------------------
# Entry point: headless vs interactive
# ---------------------------------------------------------------------------
if (@($Auto, $AutoAll, $AutoDry, $AutoDryAll) -contains $true) {
    # headless: single run, no prompts
    Run-Cleanup -Interactive:$false
} else {
    # interactive: allow repeating
    do {
        Run-Cleanup -Interactive:$true
        $resp = Read-Host 'Start over? [y/N]'
    } while ($resp.Trim().ToLower() -eq 'y')
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions