windowsweep 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ # tool_releases.ps1 - section 28: superseded tool releases and downloaded updates; the running/current one is kept.
2
+ # Seen on the P52 (2026-11-02): 27 Codex releases (11 GB) beside one current junction, Electron updater installers
3
+ # left after the app that downloaded them was removed, an Ollama update download, and Node versions no project pins.
4
+ # ASCII only.
5
+
6
+ function ConvertTo-ReleaseVersion {
7
+ <# .SYNOPSIS "0.166.0-x86_64-pc-windows-msvc" -> [version] 0.166.0; $null for any other folder shape. #>
8
+ param([string] $Name)
9
+ if ($Name -match '^(\d+)\.(\d+)\.(\d+)(-[A-Za-z0-9_.-]+)?$') { return [version]("{0}.{1}.{2}" -f $Matches[1], $Matches[2], $Matches[3]) }
10
+ return $null
11
+ }
12
+
13
+ function Get-CodexReleasePlan {
14
+ <# .SYNOPSIS Split Codex standalone releases into Keep and Drop. Kept: the `current` junction's target, every
15
+ newer release (an update not switched to yet), the newest older one (rollback) and any folder that is not a
16
+ plain version. Without a readable `current` link nothing is dropped (fail closed). -Root and -CurrentLink
17
+ let the self-test plan a fixture. #>
18
+ param([string] $Root = '', [string] $CurrentLink = '')
19
+ if (-not $Root) { $Root = Join-Path $Script:P.U '.codex\packages\standalone\releases' }
20
+ if (-not $CurrentLink) { $CurrentLink = Join-Path (Split-Path -Parent $Root) 'current' }
21
+ $plan = [pscustomobject]@{ Root = (Get-FullPath $Root); Current = $null; Keep = @(); Drop = @(); Reason = '' }
22
+ if (-not (Test-DirPresent $Root)) { $plan.Reason = 'no Codex releases folder'; return $plan }
23
+ $current = $null
24
+ try {
25
+ $link = Get-Item -LiteralPath $CurrentLink -Force -ErrorAction Stop
26
+ if (Test-ReparsePoint $link) { $current = Get-FullPath ([string](@($link.Target)[0])) }
27
+ } catch { $current = $null }
28
+ if (-not $current -or -not (Test-DirPresent $current)) { $plan.Reason = 'no readable current-release link - nothing is removed'; return $plan }
29
+ $plan.Current = $current
30
+ $ic = [StringComparison]::OrdinalIgnoreCase
31
+ $rows = @()
32
+ foreach ($e in (Get-ChildEntries $Root)) {
33
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
34
+ $rows += [pscustomobject]@{ Path = (Remove-LongPrefix $e.FullName); Version = (ConvertTo-ReleaseVersion $e.Name) }
35
+ }
36
+ $cur = @($rows | Where-Object { $_.Path.Equals($current, $ic) }) | Select-Object -First 1
37
+ if ($null -eq $cur -or $null -eq $cur.Version) {
38
+ $plan.Reason = 'the current release is not a version folder under the releases root - nothing is removed'
39
+ $plan.Keep = @($rows | ForEach-Object { $_.Path })
40
+ return $plan
41
+ }
42
+ $rollback = @($rows | Where-Object { $null -ne $_.Version -and $_.Version -lt $cur.Version } | Sort-Object Version -Descending) | Select-Object -First 1
43
+ foreach ($r in $rows) {
44
+ $keepIt = ($r.Path.Equals($current, $ic) -or $null -eq $r.Version -or $r.Version -gt $cur.Version -or ($null -ne $rollback -and $r.Path.Equals($rollback.Path, $ic)))
45
+ if ($keepIt) { $plan.Keep += $r.Path } else { $plan.Drop += $r.Path }
46
+ }
47
+ return $plan
48
+ }
49
+
50
+ function Get-CodexReleaseExceptions {
51
+ <# .SYNOPSIS The D79 exceptions for lib/safety.ps1: exactly the Drop folders of the live plan, nothing else. #>
52
+ $plan = Get-CodexReleasePlan
53
+ if ($plan.Reason) { return @() }
54
+ return @($plan.Drop)
55
+ }
56
+
57
+ function Get-BusyExecutablePaths {
58
+ <# .SYNOPSIS Executable paths of running processes (one CIM read), so a release folder in use is never removed. #>
59
+ try { return @(Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { [string]$_.ExecutablePath } | Where-Object { $_ }) } catch { return @() }
60
+ }
61
+
62
+ function Invoke-CodexReleasePrune {
63
+ <# .SYNOPSIS Remove the Drop folders through the chokepoint (which refuses anything the D79 exception does not name). #>
64
+ $plan = Get-CodexReleasePlan
65
+ if ($plan.Reason) { Write-Info "Codex releases - $($plan.Reason)"; return }
66
+ Write-Note ("Codex releases: {0} kept (current {1}), {2} superseded" -f $plan.Keep.Count, (Split-Path -Leaf $plan.Current), $plan.Drop.Count)
67
+ if ($plan.Drop.Count -eq 0) { Write-Info 'Codex releases - nothing superseded'; return }
68
+ $busy = @(Get-BusyExecutablePaths)
69
+ foreach ($d in $plan.Drop) {
70
+ $prefix = $d.TrimEnd('\') + '\'
71
+ if (@($busy | Where-Object { $_.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) }).Count -gt 0) { Write-Note "in use by a running process, kept: $d"; continue }
72
+ $r = Remove-PathSafe -Path $d -Within $plan.Root -Label 'Codex release'
73
+ if ($r.Removed -and -not $Script:WS.DryRun) { Write-Ok ("Codex release {0} removed ({1})" -f (Split-Path -Leaf $d), (Format-Bytes $r.Bytes)) }
74
+ }
75
+ }
76
+
77
+ function ConvertTo-NodePin {
78
+ <# .SYNOPSIS One version request from .nvmrc / .node-version / engines.node: exact, major, newest or unknown. #>
79
+ param([string] $Text)
80
+ $t = ([string]$Text).Trim().ToLowerInvariant()
81
+ if (-not $t) { return $null }
82
+ if ($t -in 'node', 'latest', 'stable', 'current' -or $t.StartsWith('lts')) { return [pscustomobject]@{ Kind = 'newest'; Major = 0; Version = $null; Text = $t } }
83
+ if ($t -match '^v?(\d+)\.(\d+)\.(\d+)$') { return [pscustomobject]@{ Kind = 'exact'; Major = [int]$Matches[1]; Version = [version]("{0}.{1}.{2}" -f $Matches[1], $Matches[2], $Matches[3]); Text = $t } }
84
+ if ($t -match '^[\^~>=<\s]*v?(\d+)') { return [pscustomobject]@{ Kind = 'major'; Major = [int]$Matches[1]; Version = $null; Text = $t } }
85
+ return [pscustomobject]@{ Kind = 'unknown'; Major = 0; Version = $null; Text = $t }
86
+ }
87
+
88
+ function Get-NodeVersionPlan {
89
+ <# .SYNOPSIS Which installed Node versions no project needs. Kept: the active one, every exact pin, and for a
90
+ major pin ("24", "^24", ">=24") the newest installed of that major - the one nvm would pick. An unreadable
91
+ pin keeps everything (fail closed). Pure: the self-test drives it with literals. #>
92
+ param([string[]] $Installed, [string] $Active, [pscustomobject[]] $Pins)
93
+ $plan = [pscustomobject]@{ Keep = @(); Drop = @(); Unmet = @(); Reason = '' }
94
+ $rows = @()
95
+ foreach ($i in @($Installed)) { $n = ConvertTo-NodePin $i; if ($n -and $n.Kind -eq 'exact') { $rows += [pscustomobject]@{ Name = $i; Version = $n.Version; Major = $n.Major } } }
96
+ $keep = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
97
+ if ($Active) { $null = $keep.Add($Active) }
98
+ foreach ($pin in @($Pins)) {
99
+ if ($null -eq $pin) { continue }
100
+ if ($pin.Kind -eq 'unknown') { $plan.Reason = "a version request could not be read ('$($pin.Text)') - nothing is removed"; $plan.Keep = @($Installed); return $plan }
101
+ $hit = $null
102
+ if ($pin.Kind -eq 'exact') { $hit = @($rows | Where-Object { $_.Version -eq $pin.Version }) | Select-Object -First 1 }
103
+ elseif ($pin.Kind -eq 'major') { $hit = @($rows | Where-Object { $_.Major -eq $pin.Major } | Sort-Object Version -Descending) | Select-Object -First 1 }
104
+ elseif ($pin.Kind -eq 'newest') { $hit = @($rows | Sort-Object Version -Descending) | Select-Object -First 1 }
105
+ if ($hit) { $null = $keep.Add($hit.Name) } else { $plan.Unmet += $pin.Text }
106
+ }
107
+ foreach ($r in $rows) { if ($keep.Contains($r.Name)) { $plan.Keep += $r.Name } else { $plan.Drop += $r.Name } }
108
+ return $plan
109
+ }
110
+
111
+ function Get-NodePins {
112
+ <# .SYNOPSIS Every Node version request under the scan roots: .nvmrc, .node-version and package.json engines.node,
113
+ at most four folders deep, never inside node_modules or a never-enter folder. #>
114
+ param([string[]] $Roots, [int] $MaxDepth = 4)
115
+ $pins = @()
116
+ foreach ($root in @($Roots)) {
117
+ if (-not (Test-DirPresent $root)) { continue }
118
+ $stack = New-Object System.Collections.Generic.Stack[object]
119
+ $stack.Push(@((Get-FullPath $root), 0))
120
+ $seen = 0
121
+ while ($stack.Count -gt 0 -and $seen -lt 20000) {
122
+ $item = $stack.Pop(); $dir = [string]$item[0]; $depth = [int]$item[1]
123
+ foreach ($e in (Get-ChildEntries $dir)) {
124
+ $seen++
125
+ if (Test-ReparsePoint $e) { continue }
126
+ if ($e -is [IO.DirectoryInfo]) {
127
+ if ($e.Name -eq 'node_modules' -or $e.Name.StartsWith('.') -or $Script:WS_NEVER_ENTER -contains $e.Name) { continue }
128
+ if ($depth -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), $depth + 1)) }
129
+ continue
130
+ }
131
+ $file = Remove-LongPrefix $e.FullName
132
+ if ($e.Name -in '.nvmrc', '.node-version') {
133
+ try { $pins += ConvertTo-NodePin ([IO.File]::ReadAllText($file)) } catch { $null = $_ }
134
+ } elseif ($e.Name -eq 'package.json') {
135
+ try { $j = [IO.File]::ReadAllText($file) | ConvertFrom-Json; if ($j.engines -and $j.engines.node) { $pins += ConvertTo-NodePin ([string]$j.engines.node) } } catch { $null = $_ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return @($pins | Where-Object { $_ })
141
+ }
142
+
143
+ function Invoke-NvmPrune {
144
+ <# .SYNOPSIS Remove unpinned Node versions with nvm itself (developer mode and --scan-roots only). The tool never
145
+ deletes inside the nvm folder; `nvm uninstall` does, through Invoke-External -Destructive. #>
146
+ $ws = $Script:WS
147
+ if ($ws.Developer -ne $true) { Write-Info 'Node versions - developer mode is off, left alone'; return }
148
+ if (@($ws.ScanRoots).Count -eq 0) { Write-Info 'Node versions - no --scan-roots, so project version requests cannot be read; nothing removed'; return }
149
+ $nvmHome = $env:NVM_HOME
150
+ if (-not $nvmHome) { $nvmHome = Join-Path $Script:P.L 'nvm' }
151
+ if (-not (Test-DirPresent $nvmHome)) { Write-Info 'Node versions - nvm for Windows not found'; return }
152
+ $installed = @(Get-ChildEntries $nvmHome | Where-Object { $_ -is [IO.DirectoryInfo] -and $_.Name -match '^v\d+\.\d+\.\d+$' } | ForEach-Object { $_.Name })
153
+ $active = ''
154
+ $symlink = $env:NVM_SYMLINK
155
+ if ($symlink) { try { $li = Get-Item -LiteralPath $symlink -Force -ErrorAction Stop; if (Test-ReparsePoint $li) { $active = Split-Path -Leaf ([string](@($li.Target)[0])) } } catch { $active = '' } }
156
+ if (-not $active) { Write-Info 'Node versions - the active version could not be read; nothing removed'; return }
157
+ $plan = Get-NodeVersionPlan -Installed $installed -Active $active -Pins @(Get-NodePins -Roots $ws.ScanRoots)
158
+ if ($plan.Reason) { Write-Info "Node versions - $($plan.Reason)"; return }
159
+ Write-Note ("Node versions: active {0}; kept {1}; not needed {2}" -f $active, ($plan.Keep -join ', '), ($plan.Drop -join ', '))
160
+ if (@($plan.Unmet).Count -gt 0) { Write-Note ("requested but not installed: " + (($plan.Unmet | Sort-Object -Unique) -join ', ')) }
161
+ if ($plan.Drop.Count -eq 0) { return }
162
+ if (-not (Test-CommandPresent 'nvm')) { Write-Warn ('nvm is not on PATH; remove them yourself with: nvm uninstall ' + (($plan.Drop | ForEach-Object { $_.TrimStart('v') }) -join ' / ')); return }
163
+ foreach ($v in $plan.Drop) {
164
+ $bytes = Get-DirectoryBytes (Join-Path $nvmHome $v)
165
+ $r = Invoke-External -FilePath 'nvm' -ArgumentList @('uninstall', $v.TrimStart('v')) -Destructive -Quiet -Label 'nvm'
166
+ if ($ws.DryRun) { Add-Freed $bytes; continue }
167
+ if (-not (Test-DirPresent (Join-Path $nvmHome $v))) { Add-Freed $bytes; Write-Ok ("Node {0} removed with nvm ({1})" -f $v, (Format-Bytes $bytes)) } else { Write-Warn "Node $v is still installed (nvm exit $($r.ExitCode))" }
168
+ }
169
+ }
170
+
171
+ function Get-Targets28 {
172
+ $P = $Script:P
173
+ $t = @((New-Target 28 'Codex superseded releases' (Join-Path $P.U '.codex\packages\standalone\releases') -Kind cmd -Note 'the current release, any newer one and the newest older one are kept'))
174
+ # Electron updaters named without "@" - section 8 owns the "@name-updater" ones. One target per folder, so the
175
+ # list shows each and section 23 knows the folder is cleaned here.
176
+ foreach ($e in (Get-ChildEntries $P.L)) {
177
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e) -or $e.Name.StartsWith('@') -or $e.Name -notlike '*-updater') { continue }
178
+ $t += (New-Target 28 "Electron updater downloads ($($e.Name))" (Remove-LongPrefix $e.FullName) -Mode clear-old -Days 7 -Note 'downloaded installers and pending updates idle 7+ days')
179
+ }
180
+ $t += (New-Target 28 'Ollama downloaded updates' (Join-Path $P.L 'Ollama\updates_v2') -Mode clear-old -Days 1)
181
+ $t += (New-Target 28 'Node versions no project requests (nvm)' (Join-Path $P.L 'nvm') -Kind cmd -Note 'developer mode and --scan-roots; nvm uninstall; the active and every requested version are kept')
182
+ return $t
183
+ }
184
+
185
+ function Invoke-Section28 {
186
+ Write-SectionIntro @(
187
+ 'Tools that update themselves often keep every old release. This removes the superseded ones and old downloaded',
188
+ 'updates. The release in use, any newer one and the newest older one stay, and a release a running program uses',
189
+ 'is skipped. Node versions go only in developer mode, when no project under --scan-roots asks for them.'
190
+ )
191
+ $targets = @(Get-Targets28)
192
+ $null = Show-TargetSizes $targets
193
+ if (-not (Confirm-Section 'Remove superseded releases and old downloaded updates?')) { Write-Info 'skipped'; return }
194
+ Invoke-CodexReleasePrune
195
+ $null = Invoke-TargetList @($targets | Where-Object { $_.Kind -ne 'cmd' })
196
+ Invoke-NvmPrune
197
+ }
@@ -0,0 +1,232 @@
1
+ # uninstall.ps1 - section 26: uninstall programs you pick, each with its own registered uninstaller (D77), then
2
+ # offer the folders they left (leftovers.ps1). Interactive only; --yes never selects. One program at a time:
3
+ # some uninstallers hand the work to a helper, and a second one started meanwhile fails (Adobe's "Failed to
4
+ # acquire Bootstrapper Lock", P52 2026-11-02). The silent forms below are the ones that worked on the P52 that
5
+ # day. ASCII only.
6
+
7
+ # Never offered: Windows parts, runtimes other programs need, drivers, hardware support, build kits, this tool.
8
+ $Script:WS_UNINSTALL_NEVER = @('*Redistributable*', '*Runtime*', '*Driver*', '*WebView2*', '*Chipset*', '*Firmware*',
9
+ 'Microsoft Visual C++*', 'Microsoft .NET*', 'Microsoft ASP.NET*', 'Microsoft Edge*', 'Microsoft Visual Studio Installer*',
10
+ 'Visual Studio Build Tools*', 'Windows Software Development Kit*', 'Windows SDK*', 'vs_*', 'Microsoft Update Health Tools*',
11
+ 'Update for *', 'Security Update for *', 'Windows Subsystem for Linux*', 'Intel(R) *', "Intel$([char]0x00AE) *", 'Realtek *',
12
+ 'NVIDIA PhysX*', '*Thunderbolt*', 'windowsweep*')
13
+
14
+ function Split-CommandLine {
15
+ <# .SYNOPSIS "C:\a b\u.exe" /x or C:\a b\u.exe /x -> FilePath + Arguments. An unquoted path may hold spaces
16
+ (Steam's does), so it ends at the first program extension followed by a space or the end. #>
17
+ param([string] $Text)
18
+ $t = ([string]$Text).Trim()
19
+ if ($t.StartsWith('"')) {
20
+ $end = $t.IndexOf('"', 1)
21
+ if ($end -gt 0) { return [pscustomobject]@{ FilePath = $t.Substring(1, $end - 1); Arguments = $t.Substring($end + 1).Trim() } }
22
+ }
23
+ $m = [regex]::Match($t, '^(.+?\.(?:exe|sys|dll|com|bat|cmd|msc|cpl|scr))(?:\s+(.*))?$', [Text.RegularExpressions.RegexOptions]::IgnoreCase)
24
+ if ($m.Success) { return [pscustomobject]@{ FilePath = $m.Groups[1].Value; Arguments = $m.Groups[2].Value.Trim() } }
25
+ $parts = $t -split '\s+', 2
26
+ $tail = ''
27
+ if ($parts.Count -gt 1) { $tail = $parts[1] }
28
+ return [pscustomobject]@{ FilePath = $parts[0]; Arguments = $tail }
29
+ }
30
+
31
+ function Get-AdobeBaseVersion {
32
+ <# .SYNOPSIS Adobe's silent uninstall needs the product's BASE version, which only the installer's icon folder
33
+ records (ProgramData\Adobe\Installer\Icons\<SAP>_<base>): Photoshop 2023 registered 24.1, its base was 24.0. #>
34
+ param([string] $SapCode, [string] $IconsRoot = '')
35
+ if (-not $IconsRoot) { $IconsRoot = Join-Path $Script:P.PD 'Adobe\Installer\Icons' }
36
+ foreach ($e in (Get-ChildEntries $IconsRoot)) {
37
+ if ($e -is [IO.DirectoryInfo] -and $e.Name.StartsWith("$SapCode`_", [StringComparison]::OrdinalIgnoreCase)) { return $e.Name.Substring($SapCode.Length + 1) }
38
+ }
39
+ return ''
40
+ }
41
+
42
+ function Get-UninstallCommand {
43
+ <# .SYNOPSIS The command that removes one program, as the P52 cleanup proved them. Silent when a known silent
44
+ form exists; otherwise the vendor's own uninstaller with its own window. Pure apart from Adobe's icon lookup. #>
45
+ param([pscustomobject] $Row, [string] $AdobeIconsRoot = '')
46
+ $out = [pscustomobject]@{ FilePath = ''; Arguments = ''; Strategy = 'none'; Silent = $false; SuccessCodes = @(0); Helpers = @() }
47
+ $un = [string]$Row.UninstallString
48
+ if ($Row.QuietUninstallString) {
49
+ $c = Split-CommandLine $Row.QuietUninstallString
50
+ $out.FilePath = $c.FilePath; $out.Arguments = $c.Arguments; $out.Strategy = 'quiet'; $out.Silent = $true; $out.Helpers = @('_iu*', 'unins*', 'Au_*')
51
+ return $out
52
+ }
53
+ $guid = ''
54
+ if ($un -match '(\{[0-9A-Fa-f-]{36}\})') { $guid = $Matches[1] } elseif ($Row.KeyName -match '^\{[0-9A-Fa-f-]{36}\}$') { $guid = $Row.KeyName }
55
+ if ($guid -and ($Row.WindowsInstaller -eq 1 -or $un -match '(?i)msiexec')) {
56
+ # VMware Workstation registered no command at all - only its MSI product code (P52, 2026-11-02).
57
+ $out.FilePath = 'msiexec.exe'; $out.Arguments = "/x $guid /qn /norestart"; $out.Strategy = 'msi'; $out.Silent = $true; $out.SuccessCodes = @(0, 1641, 3010)
58
+ return $out
59
+ }
60
+ if (-not $un) { return $out }
61
+ $c = Split-CommandLine $un
62
+ $exe = [IO.Path]::GetFileName($c.FilePath).ToLowerInvariant()
63
+ $out.FilePath = $c.FilePath; $out.Arguments = $c.Arguments
64
+ if ($exe -match '^unins\d{3}\.exe$') { $out.Arguments = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART'; $out.Strategy = 'inno'; $out.Silent = $true; $out.Helpers = @('_iu*', 'unins*') }
65
+ elseif ($exe -eq 'maintenancetool.exe') { $out.Arguments = 'purge --confirm-command'; $out.Strategy = 'qt-installer'; $out.Silent = $true }
66
+ elseif ($c.FilePath -match '(?i)\\Adobe Desktop Common\\HDBox\\Uninstaller\.exe$' -and $un -match '--sapCode=(\w+)') {
67
+ $sap = $Matches[1]
68
+ $platform = 'win64'
69
+ if ($un -match '--productPlatform=(\w+)') { $platform = $Matches[1] }
70
+ $base = Get-AdobeBaseVersion -SapCode $sap -IconsRoot $AdobeIconsRoot
71
+ if ($base) {
72
+ $out.FilePath = Join-Path (Split-Path -Parent $c.FilePath) 'Setup.exe'
73
+ $out.Arguments = "--uninstall=1 --sapCode=$sap --baseVersion=$base --platform=$platform --deleteUserPreferences=false"
74
+ $out.Strategy = 'adobe'; $out.Silent = $true; $out.Helpers = @('Set-up', 'HDHelper', 'Setup')
75
+ } else { $out.Strategy = 'vendor-ui' }
76
+ }
77
+ elseif ($c.FilePath -match '(?i)\\Application\\[\d.]+\\Installer\\[^\\]*setup\.exe$' -and $c.Arguments -match '--uninstall') {
78
+ # Chromium-family installers (Brave, Copilot's copilot_setup.exe): exit 19 is their success code.
79
+ if ($c.Arguments -notmatch '--force-uninstall') { $out.Arguments = ($c.Arguments + ' --force-uninstall').Trim() }
80
+ $out.Strategy = 'chromium'; $out.Silent = $true; $out.SuccessCodes = @(0, 19)
81
+ }
82
+ elseif ($exe -like '*installer.exe' -and $c.Arguments -match '(?i)\buninstall\b') {
83
+ $args2 = $c.Arguments.Replace('"', '').Trim()
84
+ if ($args2 -notmatch '--quiet') { $args2 = "$args2 --quiet" }
85
+ $out.Arguments = $args2; $out.Strategy = 'quiet-flag'; $out.Silent = $true
86
+ }
87
+ elseif ($exe -in 'uninstall.exe', 'uninst.exe', 'uninstaller.exe') {
88
+ if ($c.Arguments -cnotmatch '(^|\s)/S(\s|$)') { $out.Arguments = ($c.Arguments + ' /S').Trim() }
89
+ $out.Strategy = 'nsis'; $out.Silent = $true; $out.Helpers = @('Au_*')
90
+ }
91
+ elseif ($exe -in 'winget', 'winget.exe' -and $c.Arguments -match '^uninstall\b') {
92
+ # winget's portable packages (jq, FFmpeg, age, ... on the P52) register winget itself as their uninstaller.
93
+ $out.Arguments = ($c.Arguments + ' --silent --disable-interactivity --accept-source-agreements').Trim(); $out.Strategy = 'winget'; $out.Silent = $true
94
+ }
95
+ elseif ($exe -eq 'rustup.exe' -and $c.Arguments -match '^self uninstall\b') {
96
+ if ($c.Arguments -notmatch '(^|\s)-y(\s|$)') { $out.Arguments = ($c.Arguments + ' -y').Trim() }
97
+ $out.Strategy = 'rustup'; $out.Silent = $true
98
+ }
99
+ elseif ($c.FilePath -match '(?i)\\Package Cache\\\{[0-9A-Fa-f-]{36}\}\\[^\\]+\.exe$' -and $c.Arguments -match '(?i)/uninstall') {
100
+ # A WiX bundle in the Package Cache: /quiet is the bundle engine's own switch.
101
+ if ($c.Arguments -notmatch '(?i)/quiet') { $out.Arguments = ($c.Arguments + ' /quiet /norestart').Trim() }
102
+ $out.Strategy = 'wix-bundle'; $out.Silent = $true
103
+ }
104
+ elseif ($c.Arguments -match '(?i)(^|\s)(--yes|-y|--silent|--quiet|/quiet|/qn|/silent|/verysilent)(\s|$)' -or $c.Arguments -cmatch '(^|\s)/S(\s|$)') {
105
+ $out.Strategy = 'registered-silent'; $out.Silent = $true # Python's manager registers "uninstall --yes --by-id ..."
106
+ }
107
+ else { $out.Strategy = 'vendor-ui' }
108
+ return $out
109
+ }
110
+
111
+ function Test-UninstallOffered {
112
+ <# .SYNOPSIS False for Windows parts, updates, runtimes, drivers, build kits, this tool and entries with no uninstaller. #>
113
+ param([pscustomobject] $Row)
114
+ if (-not $Row.DisplayName -or $Row.SystemComponent -eq 1 -or $Row.ParentKeyName) { return $false }
115
+ if ($Row.ReleaseType -match '(?i)update|hotfix') { return $false }
116
+ if (-not $Row.UninstallString -and -not $Row.QuietUninstallString -and $Row.WindowsInstaller -ne 1) { return $false }
117
+ foreach ($pat in $Script:WS_UNINSTALL_NEVER) { if ($Row.DisplayName -like $pat) { return $false } }
118
+ return $true
119
+ }
120
+
121
+ function Get-UninstallCandidates {
122
+ <# .SYNOPSIS Programs section 26 may offer, largest first, one row per registry key. #>
123
+ return @(Get-InstalledProgramRows | Where-Object { Test-UninstallOffered $_ } | Sort-Object { [long]$_.EstimatedSizeKb } -Descending)
124
+ }
125
+
126
+ function Wait-UninstallDone {
127
+ <# .SYNOPSIS After the uninstaller's own process exits: wait for its registry key to go while any of its known
128
+ helper processes still runs (the work often continues there), up to MaxMinutes. Returns $true when gone. #>
129
+ param([string] $KeyPath, [string[]] $Helpers, [int] $MaxMinutes = 15)
130
+ $deadline = (Get-Date).AddMinutes($MaxMinutes)
131
+ $quietSince = Get-Date
132
+ while ((Get-Date) -lt $deadline) {
133
+ if (-not (Test-Path -LiteralPath "Registry::$KeyPath")) { return $true }
134
+ $busy = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $n = $_.ProcessName; @($Helpers | Where-Object { $n -like $_ }).Count -gt 0 })
135
+ if ($busy.Count -gt 0) { $quietSince = Get-Date } elseif (((Get-Date) - $quietSince).TotalSeconds -ge 20) { break }
136
+ Start-Sleep -Seconds 3
137
+ }
138
+ return (-not (Test-Path -LiteralPath "Registry::$KeyPath"))
139
+ }
140
+
141
+ function Invoke-ProgramUninstall {
142
+ <# .SYNOPSIS Remove one program with its own uninstaller. Returns removed | pending | failed | skipped | dry-run. #>
143
+ param([pscustomobject] $Row)
144
+ $ws = $Script:WS
145
+ $cmd = Get-UninstallCommand $Row
146
+ if ($cmd.Strategy -eq 'none') { Write-Warn "$($Row.DisplayName) - no uninstall command is registered; remove it from Settings > Apps"; return 'skipped' }
147
+ if ($cmd.FilePath -match '^[A-Za-z]:\\' -and -not (Test-Path -LiteralPath $cmd.FilePath)) {
148
+ Write-Warn "$($Row.DisplayName) - its uninstaller is missing ($($cmd.FilePath)); remove the entry from Settings > Apps"
149
+ return 'skipped'
150
+ }
151
+ $needsAdmin = ($Row.Machine -and -not $ws.IsAdmin)
152
+ $display = ('"{0}" {1}' -f $cmd.FilePath, $cmd.Arguments).Trim()
153
+ if ($ws.DryRun) {
154
+ $suffix = ''
155
+ if ($needsAdmin) { $suffix = ' (installed for all users: needs an elevated run)' }
156
+ Write-DryRun ("would uninstall {0} [{1}]: {2}{3}" -f $Row.DisplayName, $cmd.Strategy, $display, $suffix)
157
+ Add-Freed ([long]$Row.EstimatedSizeKb * 1024)
158
+ return 'dry-run'
159
+ }
160
+ if ($needsAdmin) {
161
+ Write-Warn "$($Row.DisplayName) - installed for all users, so its uninstaller needs Administrator rights (skipped)"
162
+ $ws.Hints += 'Uninstall programs installed for all users from an elevated run: windowsweep --only 26 --elevate'
163
+ return 'skipped'
164
+ }
165
+ $loc = ([string]$Row.InstallLocation).Trim().Trim('"').TrimEnd('\')
166
+ if ($loc -and (Test-DirPresent $loc)) {
167
+ $prefix = (Get-FullPath $loc) + '\'
168
+ $running = @(Get-BusyExecutablePaths | Where-Object { $_.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) })
169
+ if ($running.Count -gt 0) { Write-Warn "$($Row.DisplayName) is running ($([IO.Path]::GetFileName($running[0]))) - close it and run section 26 again"; return 'skipped' }
170
+ }
171
+ if (-not $cmd.Silent) { Write-Note "$($Row.DisplayName) has no silent form - its own uninstaller window opens; finish it there" }
172
+ Write-LogLine "uninstall [$($cmd.Strategy)]: $display"
173
+ $started = Get-Date
174
+ try {
175
+ $sp = @{ FilePath = $cmd.FilePath; PassThru = $true; ErrorAction = 'Stop' }
176
+ if ($cmd.Arguments) { $sp.ArgumentList = $cmd.Arguments }
177
+ $proc = Start-Process @sp
178
+ $null = $proc.Handle # Windows PowerShell loses ExitCode unless the handle is cached while the process runs
179
+ if (-not $proc.WaitForExit(15 * 60 * 1000)) { Write-Warn "$($Row.DisplayName) - the uninstaller is still running after 15 minutes; left running"; return 'pending' }
180
+ $code = $proc.ExitCode
181
+ } catch { Write-Err "$($Row.DisplayName) - could not start its uninstaller: $($_.Exception.Message)"; return 'failed' }
182
+ $gone = Wait-UninstallDone -KeyPath $Row.KeyPath -Helpers $cmd.Helpers
183
+ $secs = [int]((Get-Date) - $started).TotalSeconds
184
+ Write-LogLine "uninstall exit $code after ${secs}s, entry gone: $gone : $($Row.DisplayName)"
185
+ if ($gone) { Add-Freed ([long]$Row.EstimatedSizeKb * 1024); Write-Ok ("{0} uninstalled ({1}s)" -f $Row.DisplayName, $secs); return 'removed' }
186
+ if ($cmd.SuccessCodes -contains $code) { Write-Warn "$($Row.DisplayName) - the uninstaller reported success but its entry is still there (a restart may finish it)"; return 'pending' }
187
+ Write-Err "$($Row.DisplayName) - the uninstaller exited with $code and the program is still installed"
188
+ return 'failed'
189
+ }
190
+
191
+ function Get-Targets26 {
192
+ return @((New-Target 26 'Installed programs (uninstall hives)' 'HKLM + HKCU Uninstall hives' -Kind cmd -Note 'you pick; each runs its own uninstaller, one at a time; its leftover folders are offered after'))
193
+ }
194
+
195
+ function Invoke-Section26 {
196
+ $ws = $Script:WS
197
+ Write-SectionIntro @(
198
+ 'Programs you pick are removed with their own uninstaller, one at a time. Windows parts, runtimes, drivers and',
199
+ 'build kits are never listed. Programs installed for all users need an elevated run. After a real uninstall, the',
200
+ 'folders a program left are offered for the Recycle Bin. Nothing here runs without your selection.'
201
+ )
202
+ $rows = @(Get-UninstallCandidates)
203
+ if ($rows.Count -eq 0) { Write-Info 'no program can be offered'; return }
204
+ Write-UiLine (" {0,3} {1,10} {2,-46} {3,-12} {4}" -f '#', 'SIZE', 'PROGRAM', 'HOW', 'NEEDS') 'White'
205
+ $i = 1
206
+ foreach ($r in $rows) {
207
+ $cmd = Get-UninstallCommand $r
208
+ $needs = ''
209
+ if ($r.Machine) { $needs = 'admin' }
210
+ $size = 'unknown'
211
+ if ($r.EstimatedSizeKb -gt 0) { $size = Format-Bytes ([long]$r.EstimatedSizeKb * 1024) }
212
+ $name = "$($r.DisplayName) $($r.DisplayVersion)".Trim()
213
+ if ($name.Length -gt 46) { $name = $name.Substring(0, 45) + '~' }
214
+ Write-UiLine (" {0,3} {1,10} {2,-46} {3,-12} {4}" -f $i, $size, $name, $cmd.Strategy, $needs) 'Gray'
215
+ Add-JsonCandidate -Section 26 -Index $i -Path $r.KeyPath -Bytes ([long]$r.EstimatedSizeKb * 1024) -IdleDays -1 -Label $r.DisplayName
216
+ $i++
217
+ }
218
+ $picks = @(Read-MultiSelect -Total $rows.Count -NoAutoYes -Candidates @($rows | ForEach-Object { $_.KeyPath }))
219
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
220
+ if (-not $ws.DryRun) {
221
+ if (-not (Confirm-Ui -Prompt "Uninstall $($picks.Count) program(s), one at a time?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
222
+ }
223
+ $removed = @()
224
+ foreach ($k in $picks) {
225
+ $row = $rows[$k - 1]
226
+ if ((Invoke-ProgramUninstall -Row $row) -eq 'removed') { $removed += $row }
227
+ }
228
+ if ($ws.DryRun) { Write-Note 'the folders a program leaves are offered after a real uninstall'; return }
229
+ if ($removed.Count -eq 0) { return }
230
+ Write-Section 'Folders the removed programs left'
231
+ Invoke-LeftoverChoice -Rows @(Find-ProgramLeftovers -Programs $removed) -Section 26 -PathsOrConsoleOnly
232
+ }
@@ -67,9 +67,38 @@ function Get-TempRoots {
67
67
  return $roots
68
68
  }
69
69
 
70
+ function Get-DeclaredTempRoots {
71
+ <# .SYNOPSIS 1.4.0: --temp-roots / tempRoots - folders a person uses as scratch space (C:\t on the P52, where
72
+ test runs left one browser profile per run). Existing ones, once each, never a Windows temp root twice. #>
73
+ $known = @(Get-TempRoots)
74
+ $out = @()
75
+ foreach ($r in @($Script:WS.TempRoots)) {
76
+ if (-not $r -or -not (Test-DirPresent $r)) { continue }
77
+ $f = Get-FullPath $r
78
+ if ($known -notcontains $f -and $out -notcontains $f) { $out += $f }
79
+ }
80
+ return $out
81
+ }
82
+
83
+ function Get-TempRootPlan {
84
+ <# .SYNOPSIS Pure over its inputs: the top-level entries of a declared temp root that go whole - nothing inside
85
+ written for -Days or more (the caller measures write/creation time only), and not named in any running
86
+ program's command line (a test browser using one as its profile). #>
87
+ param([object[]] $Entries, [int] $Days, [string[]] $BusyText)
88
+ $out = @()
89
+ foreach ($e in @($Entries)) {
90
+ if ($e.Idle -lt $Days) { continue }
91
+ $named = $false
92
+ foreach ($b in @($BusyText)) { if ($b -and $b.IndexOf($e.Path, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $named = $true; break } }
93
+ if (-not $named) { $out += $e }
94
+ }
95
+ return $out
96
+ }
97
+
70
98
  function Get-Targets10 {
71
99
  $t = @()
72
100
  foreach ($r in (Get-TempRoots)) { $t += (New-Target 10 'User temp' $r -Mode prune -Days $Script:WS.TempDays -Note "files idle $($Script:WS.TempDays)+ days; open files are skipped") }
101
+ foreach ($r in (Get-DeclaredTempRoots)) { $t += (New-Target 10 "Declared temp root ($r)" $r -Mode clear-old -Days $Script:WS.TempDays -Note "each entry idle $($Script:WS.TempDays)+ days goes whole; one a running program names is kept") }
73
102
  return $t
74
103
  }
75
104
 
@@ -79,11 +108,26 @@ function Invoke-Section10 {
79
108
  "Temporary files idle for $($ws.TempDays)+ days. Files a running program still has open are skipped, so an",
80
109
  'installer or build that is in progress right now is safe. Lower the window with --temp-days 1 for a harder sweep.'
81
110
  )
82
- $targets = @(Get-Targets10)
83
- if ($targets.Count -eq 0) { Write-Info 'no temp folders found'; return }
111
+ $targets = @(Get-Targets10 | Where-Object { $_.Mode -ne 'clear-old' })
112
+ $declared = @(Get-DeclaredTempRoots)
113
+ if ($targets.Count -eq 0 -and $declared.Count -eq 0) { Write-Info 'no temp folders found'; return }
84
114
  $null = Show-TargetSizes $targets
115
+ $busy = @()
116
+ if ($declared.Count -gt 0) { try { $busy = @(Get-CimInstance Win32_Process -Property CommandLine -ErrorAction Stop | ForEach-Object { [string]$_.CommandLine } | Where-Object { $_ }) } catch { $busy = @() } }
117
+ $plans = @()
118
+ foreach ($root in $declared) {
119
+ $entries = @(Get-ChildEntries $root | Where-Object { -not (Test-ReparsePoint $_) } | ForEach-Object { $full = Remove-LongPrefix $_.FullName; [pscustomobject]@{ Path = $full; Idle = (Get-IdleDays $full -WriteOnly) } })
120
+ $go = @(Get-TempRootPlan -Entries $entries -Days $ws.TempDays -BusyText $busy)
121
+ Write-UiLine (" {0}: {1} of {2} entries idle {3}+ days and not in use" -f $root, $go.Count, $entries.Count, $ws.TempDays) 'Gray'
122
+ $plans += [pscustomobject]@{ Root = $root; Go = $go }
123
+ }
85
124
  if (-not (Confirm-Section "Remove temp files idle $($ws.TempDays)+ days?")) { Write-Info 'skipped'; return }
86
125
  $null = Invoke-TargetList $targets
126
+ foreach ($pl in $plans) {
127
+ $freed = [long]0; $n = 0
128
+ foreach ($e in $pl.Go) { $r = Remove-PathSafe -Path $e.Path -Within $pl.Root -Label 'declared temp root'; if ($r.Removed) { $freed += $r.Bytes; $n++ } }
129
+ if (-not $ws.DryRun -and $n -gt 0) { Write-Ok ("{0} - {1} entries removed ({2})" -f $pl.Root, $n, (Format-Bytes $freed)) }
130
+ }
87
131
  }
88
132
 
89
133
  # ---------------------------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "windowsweep",
3
- "version": "1.3.2",
4
- "description": "Developer-aware Windows cleanup CLI: dry-run first, personal folders refused, zero install via npx.",
3
+ "version": "1.4.0",
4
+ "description": "Windows cleanup CLI: dry-run first, personal files chosen row by row, zero install via npx.",
5
5
  "bin": {
6
6
  "windowsweep": "bin/windowsweep.js"
7
7
  },
package/windowsweep.ps1 CHANGED
@@ -14,10 +14,12 @@ $Script:WS_ROOT = Split-Path -Parent $MyInvocation.MyCommand.Path
14
14
 
15
15
  $Script:WS = @{
16
16
  Mode = 'walkthrough'; RawArgs = @($args); ScriptPath = $MyInvocation.MyCommand.Path
17
- DryRun = $false; Yes = $false; Deep = $false; Elevate = $false; ElevatedChild = $false; BatchMode = $false
17
+ DryRun = $false; Yes = $false; Deep = $false; Elevate = $false; ElevatedChild = $false; ElevatedSummaryToken = ''; BatchMode = $false
18
18
  Days = $null; TempDays = $null; LargeFileMb = $null; PurgeAll = $false
19
19
  DeveloperFlag = $null; ForgetDeveloper = $false; Developer = $null; DeveloperSource = ''
20
20
  ScanRoots = @(); ExcludePaths = @(); Excluded = @(); Hiberfil = ''; ResetBase = $false; Permanent = $false
21
+ BrowserProfileRoots = @(); TempRoots = @(); PersonalRoots = @(); PersonalRootsReport = @()
22
+ BrowserProfileRootsExplicit = $false; TempRootsExplicit = $false; PersonalRootsExplicit = $false
21
23
  OnlyList = ''; Profile = ''; Exclude = ''; ExportFmt = 'both'; ExportId = 'latest'; PruneDays = 90
22
24
  LogsDir = ''; ReportsDir = ''; NoReport = $false; CleanupLogs = $false
23
25
  JsonMode = $false; Quiet = $false; NoColor = $false; Ascii = $false; Notify = $false
@@ -80,7 +82,10 @@ OPTIONS
80
82
  --developer / --not-developer Override the saved developer answer for this run
81
83
  --forget-developer Ask the developer question again
82
84
  --scan-roots "P1;P2" Project roots for section 17 --exclude-path P (repeatable, all sections)
85
+ --browser-profile-roots "P1;P2" Folders of test/automation browser profiles: section 7 clears their caches
86
+ --temp-roots "P1;P2" Scratch folders: section 10 removes each entry nothing wrote for --temp-days, never one in use
83
87
  --large-file-mb N Minimum size for section 19 (default 100)
88
+ --personal-roots "P1;P2" Folders section 19 scans besides Downloads for large or old files
84
89
  --hiberfil off|reduced|keep What section 15 does --reset-base DISM /ResetBase in 14
85
90
  --permanent Sections 18, 19 and 23 delete instead of using the Recycle Bin
86
91
  --select L Pre-answer the next interactive selection, e.g. --select 1,3-5. Repeatable: the
@@ -161,6 +166,11 @@ function Read-Arguments {
161
166
  '--i-understand-deep' { $ws.Deep = $true }
162
167
  '--elevate' { $ws.Elevate = $true }
163
168
  '--elevated-child' { $ws.ElevatedChild = $true }
169
+ '--elevated-summary-token' {
170
+ $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }
171
+ if ($v -notmatch '^[a-f0-9]{32}$') { throw '--elevated-summary-token needs a 32-character hex token' }
172
+ $ws.ElevatedSummaryToken = $v
173
+ }
164
174
  { $_ -in '-d', '--days' } { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--days needs a whole number, got '$v'" }; $ws.Days = [int]$v }
165
175
  '--temp-days' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--temp-days needs a whole number, got '$v'" }; $ws.TempDays = [int]$v }
166
176
  '--purge-all' { $ws.PurgeAll = $true }
@@ -169,6 +179,9 @@ function Read-Arguments {
169
179
  '--forget-developer' { $ws.ForgetDeveloper = $true }
170
180
  '--scan-roots' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ScanRoots = @($v -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
171
181
  '--exclude-path' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ExcludePaths += $v }
182
+ '--browser-profile-roots' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.BrowserProfileRoots = @($v -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }); $ws.BrowserProfileRootsExplicit = $true }
183
+ '--temp-roots' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.TempRoots = @($v -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }); $ws.TempRootsExplicit = $true }
184
+ '--personal-roots' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.PersonalRoots = @($v -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }); $ws.PersonalRootsExplicit = $true }
172
185
  '--large-file-mb' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--large-file-mb needs a whole number, got '$v'" }; $ws.LargeFileMb = [int]$v }
173
186
  '--hiberfil' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notin 'off', 'reduced', 'keep') { throw "--hiberfil must be off, reduced or keep" }; $ws.Hiberfil = $v }
174
187
  '--reset-base' { $ws.ResetBase = $true }
@@ -239,8 +252,13 @@ function Initialize-Settings {
239
252
  if ($null -eq $ws.LargeFileMb) { $ws.LargeFileMb = [int]$cfg.largeFileMb }
240
253
  if ($ws.ScanRoots.Count -eq 0 -and $cfg.scanRoots) { $ws.ScanRoots = @($cfg.scanRoots) }
241
254
  if ($cfg.excludePaths) { $ws.ExcludePaths = @($ws.ExcludePaths) + @($cfg.excludePaths) }
255
+ if (-not $ws.BrowserProfileRootsExplicit -and $cfg.browserProfileRoots) { $ws.BrowserProfileRoots = @($cfg.browserProfileRoots) }
256
+ if (-not $ws.TempRootsExplicit -and $cfg.tempRoots) { $ws.TempRoots = @($cfg.tempRoots) }
257
+ if (-not $ws.PersonalRootsExplicit -and $cfg.personalRoots) { $ws.PersonalRoots = @($cfg.personalRoots) }
242
258
  # Must come after the merge above, or every config-file exclusion is silently dropped.
243
259
  Initialize-Exclusions
260
+ # D79: computed after the merge for the same reason - the profile roots may come from the config file.
261
+ Initialize-ProfileRootExceptions
244
262
  }
245
263
 
246
264
  function Invoke-Main {