<#
.SYNOPSIS
Safely reclaim space in C:\Windows\Installer and adjacent caches without
breaking future Windows updates, repairs, or uninstalls.
.DESCRIPTION
Enumerates installed products and patches via the WindowsInstaller COM
API (the same source of truth msiexec uses), diffs that against the
files on disk in C:\Windows\Installer, and offers four cleanup
categories:
- InstallerOrphans MSI/MSP in C:\Windows\Installer not referenced
by any installed product or patch.
- RegistryOrphans Uninstall registry entries whose target is gone.
- SupersededPatches MSP files for patches superseded by newer ones.
- WUDownload C:\Windows\SoftwareDistribution\Download\*
Runs as a CLI by default; pass -Gui for a WPF window.
.EXAMPLE
.\Clean-WindowsInstaller.ps1 -Scan -Categories All -ReportPath .\report.csv
.EXAMPLE
.\Clean-WindowsInstaller.ps1 -Clean -WhatIf
.EXAMPLE
.\Clean-WindowsInstaller.ps1 -Gui
.EXAMPLE
# Preflight: just check WinRM reachability + elevation, no staging/cleanup.
# -StartWinRM brings the service up over DCOM first if it's stopped.
.\Clean-WindowsInstaller.ps1 -Remote -ComputerName PC1,PC2 `
-Credential (Get-Credential) -TestConnection -StartWinRM
.EXAMPLE
# Scan two remote machines (read-only, nothing deleted).
.\Clean-WindowsInstaller.ps1 -Remote -ComputerName PC1,PC2 `
-Credential (Get-Credential) -Categories All
.EXAMPLE
# Actually clean a remote machine. Remote sessions are non-interactive,
# so -Force is required to authorize deletion (there is no DELETE prompt),
# and -CollectLogs pulls the run log + audit CSV back to this machine.
.\Clean-WindowsInstaller.ps1 -Remote -ComputerName PC1 `
-Credential (Get-Credential) -RemoteClean -Force `
-Categories InstallerOrphans,SupersededPatches -CollectLogs
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High',
DefaultParameterSetName = 'Scan')]
param(
[Parameter(ParameterSetName = 'Scan')]
[switch] $Scan,
[Parameter(ParameterSetName = 'Clean')]
[switch] $Clean,
[Parameter(ParameterSetName = 'Gui')]
[switch] $Gui,
[Parameter(ParameterSetName = 'Remote', Mandatory)]
[switch] $Remote,
[Parameter(ParameterSetName = 'Remote', Mandatory)]
[Alias('Cn','Computer')]
[string[]] $ComputerName,
[Parameter(ParameterSetName = 'Remote')]
[pscredential] $Credential,
# When set, performs deletion on the remote machine. Without it, the remote
# run is scan-only. Requires -Force because a remote session cannot prompt.
[Parameter(ParameterSetName = 'Remote')]
[switch] $RemoteClean,
# Connect to the remote WinRM endpoint over HTTPS (port 5986).
[Parameter(ParameterSetName = 'Remote')]
[switch] $UseSSL,
# Copy each remote machine's run log + audit CSV back to this machine.
[Parameter(ParameterSetName = 'Remote')]
[switch] $CollectLogs,
# Preflight only: check WinRM reachability + elevation on each target and
# report, without staging the script or cleaning anything.
[Parameter(ParameterSetName = 'Remote')]
[switch] $TestConnection,
# If WinRM is not running on a target, start it first via CIM/DCOM (which
# does not need WinRM). Also flips Disabled -> Automatic so it survives.
[Parameter(ParameterSetName = 'Remote')]
[switch] $StartWinRM,
[ValidateSet('InstallerOrphans','RegistryOrphans','SupersededPatches','WUDownload','All')]
[string[]] $Categories = @('InstallerOrphans'),
[switch] $Force,
[string] $ReportPath,
[string] $LogPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
$script:InstallerDir = Join-Path $env:SystemRoot 'Installer'
$script:WUDownloadDir = Join-Path $env:SystemRoot 'SoftwareDistribution\Download'
$script:DataRoot = Join-Path $env:ProgramData 'WindowsInstallerCleaner'
$script:LogDir = Join-Path $script:DataRoot 'logs'
# Expand 'All' to the concrete set.
if ($Categories -contains 'All') {
$Categories = @('InstallerOrphans','RegistryOrphans','SupersededPatches','WUDownload')
}
# ---------------------------------------------------------------------------
# Sanity + elevation
# ---------------------------------------------------------------------------
function Test-Admin {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
([Security.Principal.WindowsPrincipal]$id).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Assert-Environment {
if ($env:SystemRoot -ne 'C:\Windows') {
throw "Refusing to run: SystemRoot is '$env:SystemRoot' (expected C:\Windows)."
}
if (-not (Test-Path $script:InstallerDir)) {
throw "Refusing to run: $script:InstallerDir does not exist."
}
}
function Invoke-SelfElevate {
param([string[]] $OriginalArgs)
if (Test-Admin) { return }
Write-Host "Re-launching elevated..." -ForegroundColor Yellow
$argLine = ($OriginalArgs | ForEach-Object {
if ($_ -match '\s') { '"' + $_.Replace('"','`"') + '"' } else { $_ }
}) -join ' '
$scriptArg = '-NoProfile -ExecutionPolicy Bypass -File "{0}" {1}' -f $PSCommandPath, $argLine
Start-Process -FilePath 'powershell.exe' -ArgumentList $scriptArg -Verb RunAs | Out-Null
exit 0
}
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
function Start-RunLog {
if (-not (Test-Path $script:LogDir)) {
New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null
}
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$script:RunStamp = $stamp
$script:TranscriptPath = if ($LogPath) { $LogPath } else {
Join-Path $script:LogDir "$stamp.log"
}
$script:AuditCsvPath = Join-Path $script:LogDir "$stamp-removed.csv"
Start-Transcript -Path $script:TranscriptPath -Append | Out-Null
}
function Stop-RunLog {
try { Stop-Transcript | Out-Null } catch { }
}
function Write-Audit {
param([Parameter(Mandatory)] [object[]] $Items)
if (-not $Items -or $Items.Count -eq 0) { return }
$Items | Select-Object Path, Size, Category,
@{N='Timestamp'; E={ (Get-Date).ToString('s') }} |
Export-Csv -Path $script:AuditCsvPath -NoTypeInformation -Append
}
# ---------------------------------------------------------------------------
# Discovery: build the set of MSI/MSP files actually referenced by installed
# products and patches. This is the safety-critical function.
# ---------------------------------------------------------------------------
function New-MsiInstaller {
# Factored out so tests can mock the COM object.
New-Object -ComObject WindowsInstaller.Installer
}
function Get-ComProperty {
# Strict mode v3+ rejects direct property access on IDispatch COM objects
# ("property X cannot be found"). Reading through InvokeMember bypasses
# the reflection check and goes through IDispatch directly.
param(
[Parameter(Mandatory)] [object] $Target,
[Parameter(Mandatory)] [string] $Name
)
$Target.GetType().InvokeMember(
$Name,
[System.Reflection.BindingFlags]::GetProperty,
$null,
$Target,
@())
}
function Get-ReferencedInstallerFile {
[CmdletBinding()]
param(
[object] $Installer = (New-MsiInstaller)
)
$referenced = New-Object 'System.Collections.Generic.HashSet[string]' (
[System.StringComparer]::OrdinalIgnoreCase)
foreach ($productCode in @(Get-ComProperty -Target $Installer -Name 'Products')) {
try {
$local = $Installer.ProductInfo($productCode, 'LocalPackage')
if ($local) { [void] $referenced.Add($local) }
} catch {
Write-Verbose "ProductInfo failed for $productCode : $_"
}
try {
$patches = @($Installer.Patches($productCode))
} catch {
Write-Verbose "Patches() failed for $productCode : $_"
$patches = @()
}
foreach ($patchCode in $patches) {
try {
$local = $Installer.PatchInfo($patchCode, 'LocalPackage')
if ($local) { [void] $referenced.Add($local) }
} catch {
Write-Verbose "PatchInfo failed for $patchCode : $_"
}
}
}
return ,$referenced
}
function Get-MsiProductName {
param(
[Parameter(Mandatory)] [string] $Path,
[object] $Installer = (New-MsiInstaller)
)
try {
# 0 = msiOpenDatabaseModeReadOnly
$db = $Installer.OpenDatabase($Path, 0)
$view = $db.OpenView("SELECT Value FROM Property WHERE Property='ProductName'")
$view.Execute($null)
$rec = $view.Fetch()
if ($rec) { return $rec.StringData(1) }
} catch { }
return $null
}
# ---------------------------------------------------------------------------
# Category 1: orphan MSI/MSP in C:\Windows\Installer
# ---------------------------------------------------------------------------
function Get-InstallerOrphan {
[CmdletBinding()]
param(
[object] $Installer = (New-MsiInstaller)
)
$referenced = Get-ReferencedInstallerFile -Installer $Installer
Write-Verbose "Referenced MSI/MSP count: $($referenced.Count)"
Get-ChildItem -Path $script:InstallerDir -Force -File `
-ErrorAction SilentlyContinue |
Where-Object { $_.Extension -in '.msi','.msp' -and
-not $referenced.Contains($_.FullName) } |
ForEach-Object {
[pscustomobject] @{
Category = 'InstallerOrphans'
Path = $_.FullName
Size = $_.Length
LastModified = $_.LastWriteTime
Product = (Get-MsiProductName -Path $_.FullName -Installer $Installer)
}
}
}
# ---------------------------------------------------------------------------
# Category 2: dead Uninstall registry entries
# ---------------------------------------------------------------------------
function Get-UninstallRegistryOrphan {
[CmdletBinding()]
param(
[object] $Installer = (New-MsiInstaller)
)
$installedProductCodes = New-Object 'System.Collections.Generic.HashSet[string]' (
[System.StringComparer]::OrdinalIgnoreCase)
foreach ($pc in @(Get-ComProperty -Target $Installer -Name 'Products')) {
[void] $installedProductCodes.Add($pc)
}
$roots = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
)
foreach ($root in $roots) {
if (-not (Test-Path $root)) { continue }
Get-ChildItem -Path $root -ErrorAction SilentlyContinue | ForEach-Object {
$key = $_
$props = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue
if (-not $props) { return }
$display = $props.DisplayName
$installLoc = $props.InstallLocation
$uninstall = $props.UninstallString
$name = $key.PSChildName
$isMsiGuid = $name -match '^\{[0-9A-Fa-f-]+\}$'
$productCodeStillInstalled = $isMsiGuid -and $installedProductCodes.Contains($name)
$installLocMissing = $installLoc -and -not (Test-Path -LiteralPath $installLoc)
$uninstallMissing = $false
if ($uninstall -and $uninstall -notmatch '(?i)msiexec') {
# Pull the executable path from the UninstallString and check it.
$exePath = $null
if ($uninstall -match '^"([^"]+)"') { $exePath = $Matches[1] }
elseif ($uninstall -match '^(\S+)') { $exePath = $Matches[1] }
if ($exePath -and -not (Test-Path -LiteralPath $exePath)) {
$uninstallMissing = $true
}
}
$isOrphan = $false
$reason = $null
if ($isMsiGuid -and -not $productCodeStillInstalled) {
$isOrphan = $true
$reason = 'MSI product code not installed'
} elseif ($installLocMissing -and ($uninstallMissing -or -not $uninstall)) {
$isOrphan = $true
$reason = 'InstallLocation gone and no working UninstallString'
}
if ($isOrphan -and $display) {
[pscustomobject] @{
Category = 'RegistryOrphans'
Path = $key.PSPath
Size = 0
LastModified = $null
Product = $display
Reason = $reason
}
}
}
}
}
# ---------------------------------------------------------------------------
# Category 3: superseded MSP patches
# ---------------------------------------------------------------------------
function Get-SupersededPatch {
[CmdletBinding()]
param(
[object] $Installer = (New-MsiInstaller)
)
# PatchesEx(patchCode=null, productCode=null, context=7=All, filter=2=Superseded)
$supersededFilter = 2
$allContexts = 7
try {
$patches = @($Installer.PatchesEx($null, $null, $allContexts, $supersededFilter))
} catch {
Write-Warning "PatchesEx failed; cannot enumerate superseded patches: $_"
return
}
foreach ($patch in $patches) {
$local = $null
try { $local = $patch.PatchProperty('LocalPackage') } catch { }
if (-not $local -or -not (Test-Path -LiteralPath $local)) { continue }
$info = Get-Item -LiteralPath $local
[pscustomobject] @{
Category = 'SupersededPatches'
Path = $info.FullName
Size = $info.Length
LastModified = $info.LastWriteTime
Product = $patch.PatchProperty('DisplayName')
}
}
}
# ---------------------------------------------------------------------------
# Category 4: SoftwareDistribution\Download cleanup
# ---------------------------------------------------------------------------
function Get-WUDownloadItem {
if (-not (Test-Path $script:WUDownloadDir)) { return }
Get-ChildItem -Path $script:WUDownloadDir -Force -Recurse -File `
-ErrorAction SilentlyContinue | ForEach-Object {
[pscustomobject] @{
Category = 'WUDownload'
Path = $_.FullName
Size = $_.Length
LastModified = $_.LastWriteTime
Product = 'Windows Update payload'
}
}
}
function Stop-WUServices {
foreach ($svc in 'wuauserv','bits','cryptSvc','msiserver') {
try { Stop-Service -Name $svc -Force -ErrorAction Stop } catch {
Write-Verbose "Stop-Service $svc : $_"
}
}
}
function Start-WUServices {
foreach ($svc in 'msiserver','cryptSvc','bits','wuauserv') {
try { Start-Service -Name $svc -ErrorAction Stop } catch {
Write-Verbose "Start-Service $svc : $_"
}
}
}
# ---------------------------------------------------------------------------
# Discovery dispatcher
# ---------------------------------------------------------------------------
function Get-CleanupCandidate {
[CmdletBinding()]
param(
[string[]] $Category,
[object] $Installer = (New-MsiInstaller)
)
foreach ($c in $Category) {
switch ($c) {
'InstallerOrphans' { Get-InstallerOrphan -Installer $Installer }
'RegistryOrphans' { Get-UninstallRegistryOrphan -Installer $Installer }
'SupersededPatches' { Get-SupersededPatch -Installer $Installer }
'WUDownload' { Get-WUDownloadItem }
}
}
}
# ---------------------------------------------------------------------------
# Reporting + deletion
# ---------------------------------------------------------------------------
function Format-Bytes {
param([long] $Bytes)
$units = 'B','KB','MB','GB','TB'
$i = 0; $n = [double]$Bytes
while ($n -ge 1024 -and $i -lt $units.Count - 1) { $n /= 1024; $i++ }
'{0:N2} {1}' -f $n, $units[$i]
}
function Write-CandidateSummary {
param([object[]] $Items)
if (-not $Items) { Write-Host "Nothing found."; return }
$byCat = $Items | Group-Object Category
foreach ($g in $byCat) {
$sum = ($g.Group | Measure-Object Size -Sum).Sum
Write-Host ("{0,-20} {1,6} items {2}" -f $g.Name, $g.Count, (Format-Bytes $sum))
}
$total = ($Items | Measure-Object Size -Sum).Sum
Write-Host ("{0,-20} {1,6} items {2}" -f 'TOTAL', $Items.Count, (Format-Bytes $total)) `
-ForegroundColor Cyan
}
function Backup-RegistryKey {
param([Parameter(Mandatory)] [string] $PSPath)
# Convert provider path "Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\..."
# to "HKLM\..." for reg.exe.
$regPath = $PSPath -replace '^Microsoft\.PowerShell\.Core\\Registry::',''
$regPath = $regPath -replace '^HKEY_LOCAL_MACHINE','HKLM' `
-replace '^HKEY_CURRENT_USER','HKCU' `
-replace '^HKEY_CLASSES_ROOT','HKCR' `
-replace '^HKEY_USERS','HKU'
$safe = ($regPath -replace '[\\:]','_')
$backupFile = Join-Path $script:LogDir "$($script:RunStamp)-reg-$safe.reg"
& reg.exe export $regPath $backupFile /y | Out-Null
return $backupFile
}
function Invoke-Removal {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)] [object[]] $Items,
[switch] $SkipConfirmation
)
if (-not $Items -or $Items.Count -eq 0) {
Write-Host "Nothing to delete."
return
}
Write-CandidateSummary -Items $Items
$touchesInstallerDir = $Items | Where-Object {
$_.Category -in 'InstallerOrphans','SupersededPatches'
} | Select-Object -First 1
$touchesWUDownload = $Items | Where-Object { $_.Category -eq 'WUDownload' } |
Select-Object -First 1
if (-not $SkipConfirmation -and -not $WhatIfPreference) {
Write-Host ""
Write-Host "Type DELETE to proceed, anything else to abort: " -ForegroundColor Yellow -NoNewline
$resp = Read-Host
if ($resp -ne 'DELETE') {
Write-Host "Aborted." -ForegroundColor Red
return
}
}
if (($touchesInstallerDir -or $touchesWUDownload) -and -not $WhatIfPreference) {
Stop-WUServices
}
try {
$removed = New-Object System.Collections.Generic.List[object]
foreach ($item in $Items) {
$target = $item.Path
if (-not $PSCmdlet.ShouldProcess($target, "Remove ($($item.Category))")) {
continue
}
try {
if ($item.Category -eq 'RegistryOrphans') {
$backup = Backup-RegistryKey -PSPath $target
Write-Verbose "Backed up registry key to $backup"
Remove-Item -Path $target -Recurse -Force
} else {
Remove-Item -LiteralPath $target -Force
}
$removed.Add($item)
} catch {
Write-Warning "Failed to remove $target : $_"
}
}
Write-Audit -Items $removed.ToArray()
Write-Host ("Removed {0} of {1} items." -f $removed.Count, $Items.Count) `
-ForegroundColor Green
} finally {
if (($touchesInstallerDir -or $touchesWUDownload) -and -not $WhatIfPreference) {
Start-WUServices
}
}
}
# ---------------------------------------------------------------------------
# GUI (WPF, embedded XAML)
# ---------------------------------------------------------------------------
function Show-Gui {
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase
[xml] $xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Windows Installer Cleaner" Height="640" Width="980"
WindowStartupLocation="CenterScreen">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="8">
<TextBlock Text="Categories:" VerticalAlignment="Center" Margin="0,0,8,0"/>
<CheckBox x:Name="cbInstaller" Content="Installer orphans" IsChecked="True" Margin="4"/>
<CheckBox x:Name="cbRegistry" Content="Registry orphans" Margin="4"/>
<CheckBox x:Name="cbSuper" Content="Superseded patches" Margin="4"/>
<CheckBox x:Name="cbWU" Content="WU Download cache" Margin="4"/>
<Button x:Name="btnScan" Content="Scan" Width="90" Margin="16,0,0,0"/>
</StackPanel>
<DockPanel DockPanel.Dock="Bottom" LastChildFill="False" Margin="8">
<TextBlock x:Name="tbStatus" DockPanel.Dock="Left" VerticalAlignment="Center"
Text="Idle."/>
<Button x:Name="btnDelete" DockPanel.Dock="Right" Width="220" Height="32"
Content="Delete selected" Background="#C62828" Foreground="White"
FontWeight="Bold" IsEnabled="False"/>
</DockPanel>
<DataGrid x:Name="grid" AutoGenerateColumns="False" CanUserAddRows="False"
Margin="8" SelectionMode="Extended">
<DataGrid.Columns>
<DataGridCheckBoxColumn Header="" Binding="{Binding Selected, Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}" Width="32"/>
<DataGridTextColumn Header="Category" Binding="{Binding Category}" Width="140"/>
<DataGridTextColumn Header="Product" Binding="{Binding Product}" Width="240"/>
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*"/>
<DataGridTextColumn Header="Size" Binding="{Binding SizeText}" Width="100"/>
<DataGridTextColumn Header="Modified" Binding="{Binding LastModified}" Width="140"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Window>
'@
$reader = New-Object System.Xml.XmlNodeReader $xaml
$window = [Windows.Markup.XamlReader]::Load($reader)
foreach ($name in 'cbInstaller','cbRegistry','cbSuper','cbWU','btnScan',
'btnDelete','grid','tbStatus') {
Set-Variable -Name $name -Value $window.FindName($name) -Scope 1
}
$items = New-Object System.Collections.ObjectModel.ObservableCollection[object]
$grid.ItemsSource = $items
$updateStatus = {
$sel = @($items | Where-Object Selected)
$sumSel = ($sel | Measure-Object Size -Sum).Sum
$sumAll = ($items | Measure-Object Size -Sum).Sum
$tbStatus.Text = "Found {0} items ({1}). Selected {2} ({3})." -f `
$items.Count, (Format-Bytes ([long]$sumAll)),
$sel.Count, (Format-Bytes ([long]$sumSel))
$btnDelete.IsEnabled = $sel.Count -gt 0
}
$btnScan.Add_Click({
$tbStatus.Text = "Scanning..."
$window.Cursor = [System.Windows.Input.Cursors]::Wait
try {
$items.Clear()
$cats = @()
if ($cbInstaller.IsChecked) { $cats += 'InstallerOrphans' }
if ($cbRegistry.IsChecked) { $cats += 'RegistryOrphans' }
if ($cbSuper.IsChecked) { $cats += 'SupersededPatches' }
if ($cbWU.IsChecked) { $cats += 'WUDownload' }
if ($cats.Count -eq 0) {
$tbStatus.Text = "Pick at least one category."
return
}
$installer = New-MsiInstaller
foreach ($row in (Get-CleanupCandidate -Category $cats -Installer $installer)) {
$row | Add-Member -NotePropertyName Selected -NotePropertyValue $true -Force
$row | Add-Member -NotePropertyName SizeText `
-NotePropertyValue (Format-Bytes ([long]$row.Size)) -Force
$items.Add($row)
}
& $updateStatus
} finally {
$window.Cursor = $null
}
})
$grid.Add_CellEditEnding({ $window.Dispatcher.InvokeAsync($updateStatus) | Out-Null })
$btnDelete.Add_Click({
$sel = @($items | Where-Object Selected)
if ($sel.Count -eq 0) { return }
$sumSel = ($sel | Measure-Object Size -Sum).Sum
$msg = "Permanently delete $($sel.Count) items ($(Format-Bytes ([long]$sumSel)))?`n`n" +
"This cannot be undone (registry keys are backed up to .reg files)."
$res = [System.Windows.MessageBox]::Show($msg, 'Confirm delete',
'YesNo', 'Warning')
if ($res -ne 'Yes') { return }
$window.Cursor = [System.Windows.Input.Cursors]::Wait
try {
Invoke-Removal -Items $sel -SkipConfirmation
foreach ($row in $sel) { $items.Remove($row) | Out-Null }
& $updateStatus
} finally {
$window.Cursor = $null
}
})
$window.ShowDialog() | Out-Null
}
# ---------------------------------------------------------------------------
# Remote orchestration
#
# Stages this exact script onto each target over PowerShell Remoting and runs
# it there, so the safety-critical discovery (Get-ReferencedInstallerFile et al)
# executes against the remote machine's own WindowsInstaller database. The
# orchestrator side needs no elevation; the remote session does, and we verify
# it up front because a non-interactive remote session cannot self-elevate.
# ---------------------------------------------------------------------------
function Start-RemoteWinRM {
# Ensure the WinRM service is running on a remote machine WITHOUT using
# WinRM (which may be the thing that's down). Goes over CIM/DCOM (RPC),
# so it needs SCM/DCOM reachability + admin rights, not a WSMan listener.
# Returns $true if WinRM ends up running. Never throws.
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $ComputerName,
[pscredential] $Credential
)
$cim = $null
try {
$opt = New-CimSessionOption -Protocol Dcom
$cimParams = @{ ComputerName = $ComputerName; SessionOption = $opt; ErrorAction = 'Stop' }
if ($Credential) { $cimParams.Credential = $Credential }
$cim = New-CimSession @cimParams
$svc = Get-CimInstance -CimSession $cim -ClassName Win32_Service `
-Filter "Name='WinRM'" -ErrorAction Stop
if (-not $svc) {
Write-Warning ("{0}: WinRM service not found." -f $ComputerName)
return $false
}
if ($svc.State -eq 'Running') { return $true }
Write-Host ("{0}: WinRM is {1}; attempting to start it via DCOM..." -f `
$ComputerName, $svc.State)
# A Disabled service can't be started until its start mode is changed.
if ($svc.StartMode -eq 'Disabled') {
$r = Invoke-CimMethod -InputObject $svc -MethodName ChangeStartMode `
-Arguments @{ StartMode = 'Automatic' } -ErrorAction Stop
if ($r.ReturnValue -ne 0) {
Write-Warning ("{0}: ChangeStartMode failed (code {1})." -f $ComputerName, $r.ReturnValue)
}
}
$start = Invoke-CimMethod -InputObject $svc -MethodName StartService -ErrorAction Stop
# 0 = success, 10 = service already running (race).
if ($start.ReturnValue -notin 0, 10) {
Write-Warning ("{0}: StartService failed (code {1})." -f $ComputerName, $start.ReturnValue)
return $false
}
$svc = Get-CimInstance -CimSession $cim -ClassName Win32_Service `
-Filter "Name='WinRM'" -ErrorAction SilentlyContinue
$running = $svc -and $svc.State -eq 'Running'
if ($running) { Write-Host ("{0}: WinRM started." -f $ComputerName) -ForegroundColor Green }
return $running
} catch {
Write-Warning ("{0}: could not start WinRM over DCOM: {1}" -f $ComputerName, $_)
return $false
} finally {
if ($cim) { Remove-CimSession $cim -ErrorAction SilentlyContinue }
}
}
function Test-RemoteWinRM {
# Probe WinRM on one machine without opening a full session. Returns a
# result object; never throws. Used for the -TestConnection preflight.
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $ComputerName,
[pscredential] $Credential,
[switch] $UseSSL,
[switch] $StartWinRM
)
$result = [pscustomobject] @{
ComputerName = $ComputerName
Reachable = $false
Elevated = $false
Detail = $null
}
$probe = {
$wsmanParams = @{ ComputerName = $ComputerName; ErrorAction = 'Stop' }
if ($Credential) { $wsmanParams.Credential = $Credential; $wsmanParams.Authentication = 'Default' }
if ($UseSSL) { $wsmanParams.UseSSL = $true }
Test-WSMan @wsmanParams | Out-Null
}
# Test-WSMan is the cheapest "is the listener up?" probe.
try {
& $probe
$result.Reachable = $true
} catch {
# Listener down: optionally bring WinRM up via DCOM, then re-probe.
if ($StartWinRM -and (Start-RemoteWinRM -ComputerName $ComputerName -Credential $Credential)) {
try { & $probe; $result.Reachable = $true }
catch { $result.Detail = "WinRM started but still unreachable: $_"; return $result }
} else {
$result.Detail = "WinRM unreachable: $_"
return $result
}
}
# Reachable: confirm the credential actually opens an elevated session.
$session = $null
try {
$sessParams = @{ ComputerName = $ComputerName; ErrorAction = 'Stop' }
if ($Credential) { $sessParams.Credential = $Credential }
if ($UseSSL) { $sessParams.UseSSL = $true }
$session = New-PSSession @sessParams
$result.Elevated = Invoke-Command -Session $session -ScriptBlock {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
([Security.Principal.WindowsPrincipal]$id).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
$result.Detail = if ($result.Elevated) {
'OK: reachable and session is elevated.'
} else {
'Reachable but session is NOT elevated (connect as a local admin).'
}
} catch {
$result.Detail = "Reachable but session failed: $_"
} finally {
if ($session) { Remove-PSSession $session }
}
return $result
}
function Invoke-RemoteCleanup {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string[]] $ComputerName,
[pscredential] $Credential,
[string[]] $Categories,
[switch] $DoClean,
[switch] $Force,
[switch] $UseSSL,
[switch] $CollectLogs,
[switch] $TestConnection,
[switch] $StartWinRM
)
# Preflight-only mode: report reachability/elevation and stop.
if ($TestConnection) {
$results = foreach ($computer in $ComputerName) {
Write-Host ("Testing {0}..." -f $computer)
Test-RemoteWinRM -ComputerName $computer -Credential $Credential `
-UseSSL:$UseSSL -StartWinRM:$StartWinRM
}
$results | Format-Table ComputerName, Reachable, Elevated, Detail -AutoSize |
Out-Host
$ready = @($results | Where-Object { $_.Reachable -and $_.Elevated })
Write-Host ("{0} of {1} target(s) ready for cleanup." -f `
$ready.Count, @($results).Count) -ForegroundColor Cyan
return
}
if ($DoClean -and -not $Force) {
throw ("Remote clean is non-interactive (there is no DELETE prompt over " +
"a remote session). Re-run with -Force to authorize deletion.")
}
foreach ($computer in $ComputerName) {
Write-Host ""
Write-Host ("=== {0} ===" -f $computer) -ForegroundColor Cyan
$sessionParams = @{ ComputerName = $computer; ErrorAction = 'Stop' }
if ($Credential) { $sessionParams.Credential = $Credential }
if ($UseSSL) { $sessionParams.UseSSL = $true }
# Make sure WinRM is up first (over DCOM) when asked.
if ($StartWinRM) {
Start-RemoteWinRM -ComputerName $computer -Credential $Credential | Out-Null
}
$session = $null
$remotePath = $null
try {
try {
$session = New-PSSession @sessionParams
} catch {
Write-Warning ("Cannot open a remoting session to {0}: {1}" -f $computer, $_)
continue
}
# The remote session must already be elevated; it cannot self-elevate.
$isAdmin = Invoke-Command -Session $session -ScriptBlock {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
([Security.Principal.WindowsPrincipal]$id).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not $isAdmin) {
Write-Warning ("Remote session on {0} is not elevated; skipping. " +
"Connect with a local administrator account." -f $computer)
continue
}
# Stage this script in the remote temp dir under a unique name.
$remotePath = Invoke-Command -Session $session -ScriptBlock {
Join-Path $env:TEMP ("Clean-WindowsInstaller-{0}.ps1" -f `
([guid]::NewGuid().ToString('N')))
}
Copy-Item -LiteralPath $PSCommandPath -Destination $remotePath `
-ToSession $session -Force
$mode = if ($DoClean) { 'clean' } else { 'scan' }
Write-Host ("Running {0} on {1} (categories: {2})..." -f `
$mode, $computer, ($Categories -join ', '))
# Run the staged script remotely, then hand back the newest log files
# so the operator can archive proof of exactly what was touched.
$remoteLogs = Invoke-Command -Session $session -ScriptBlock {
param($scriptPath, $doClean, $force, $categories)
$p = @{ Categories = $categories }
if ($doClean) {
$p.Clean = $true
if ($force) { $p.Force = $true }
} else {
$p.Scan = $true
}
& $scriptPath @p
$logDir = Join-Path $env:ProgramData 'WindowsInstallerCleaner\logs'
if (Test-Path $logDir) {
Get-ChildItem -Path $logDir -File |
Sort-Object LastWriteTime -Descending |
Select-Object -First 6 -ExpandProperty FullName
}
} -ArgumentList $remotePath, $DoClean.IsPresent, $Force.IsPresent, $Categories
if ($CollectLogs -and $remoteLogs) {
$dest = Join-Path $script:LogDir ("remote\{0}" -f $computer)
if (-not (Test-Path $dest)) {
New-Item -ItemType Directory -Path $dest -Force | Out-Null
}
foreach ($log in @($remoteLogs | Where-Object {
$_ -is [string] -and $_ -match '\.(log|csv|reg)$' })) {
try {
Copy-Item -LiteralPath $log -Destination $dest `
-FromSession $session -Force
} catch {
Write-Verbose ("Could not collect {0}: {1}" -f $log, $_)
}
}
Write-Host ("Collected remote logs to {0}" -f $dest)
}
} catch {
Write-Warning ("Cleanup on {0} failed: {1}" -f $computer, $_)
} finally {
if ($session) {
if ($remotePath) {
try {
Invoke-Command -Session $session -ScriptBlock {
param($p)
if ($p -and (Test-Path -LiteralPath $p)) {
Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue
}
} -ArgumentList $remotePath
} catch { }
}
Remove-PSSession $session
}
}
}
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
# Skip elevation/IO setup when the script is dot-sourced for testing.
if ($MyInvocation.InvocationName -eq '.' -or $MyInvocation.Line -match '^\s*\. ') {
return
}
# Remote orchestration runs locally without elevation and targets other
# machines, so it skips the local self-elevate / environment assertions. It
# still keeps a local transcript of the orchestration run.
if ($PSCmdlet.ParameterSetName -eq 'Remote') {
Start-RunLog
try {
Invoke-RemoteCleanup -ComputerName $ComputerName -Credential $Credential `
-Categories $Categories -DoClean:$RemoteClean -Force:$Force `
-UseSSL:$UseSSL -CollectLogs:$CollectLogs -TestConnection:$TestConnection `
-StartWinRM:$StartWinRM
} finally {
Stop-RunLog
}
return
}
Invoke-SelfElevate -OriginalArgs $MyInvocation.UnboundArguments
Assert-Environment
Start-RunLog
try {
switch ($PSCmdlet.ParameterSetName) {
'Gui' {
Show-Gui
}
'Clean' {
$installer = New-MsiInstaller
$items = @(Get-CleanupCandidate -Category $Categories -Installer $installer)
if ($ReportPath) {
$items | Export-Csv -Path $ReportPath -NoTypeInformation
Write-Host "Wrote report to $ReportPath"
}
Invoke-Removal -Items $items -SkipConfirmation:$Force
}
default {
# Scan
$installer = New-MsiInstaller
$items = @(Get-CleanupCandidate -Category $Categories -Installer $installer)
Write-CandidateSummary -Items $items
if ($ReportPath) {
$items | Export-Csv -Path $ReportPath -NoTypeInformation
Write-Host "Wrote report to $ReportPath"
}
}
}
} finally {
Stop-RunLog
}
By default, Windows blocks unsigned PowerShell scripts from running. Pick one of the options below to allow it.
Open PowerShell in the folder containing the script and run:
Open PowerShell as Administrator and run:
RemoteSigned lets local scripts run while still requiring signatures on scripts downloaded from the internet.
If you want zero restrictions, run PowerShell as Administrator and:
Downloaded files get a "Mark of the Web" flag. Unblock with: