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.
- package/AI-INTEGRATION-GUIDE.md +8 -8
- package/CHANGELOG.md +80 -0
- package/README.md +27 -28
- package/VERSION +1 -1
- package/lib/actions.ps1 +13 -0
- package/lib/config.ps1 +4 -0
- package/lib/constants.ps1 +12 -8
- package/lib/external.ps1 +13 -0
- package/lib/fs.ps1 +116 -14
- package/lib/safety.ps1 +48 -23
- package/lib/ui.ps1 +20 -2
- package/modules/android_avd.ps1 +187 -36
- package/modules/browser_ai.ps1 +68 -0
- package/modules/browsers.ps1 +101 -33
- package/modules/data_scanner.ps1 +158 -0
- package/modules/globals_audit.ps1 +5 -3
- package/modules/installed_programs.ps1 +69 -2
- package/modules/leftovers.ps1 +388 -0
- package/modules/orphaned_appdata.ps1 +47 -3
- package/modules/personal.ps1 +7 -37
- package/modules/projects.ps1 +2 -1
- package/modules/runner.ps1 +29 -5
- package/modules/self_test_1_4.ps1 +215 -0
- package/modules/self_test_1_4b.ps1 +418 -0
- package/modules/self_test_extra.ps1 +9 -0
- package/modules/system_admin.ps1 +17 -3
- package/modules/tool_releases.ps1 +197 -0
- package/modules/uninstall.ps1 +232 -0
- package/modules/windows_user.ps1 +46 -2
- package/package.json +2 -2
- package/windowsweep.ps1 +19 -1
|
@@ -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
|
+
}
|
package/modules/windows_user.ps1
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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 {
|