windowsweep 1.1.0 → 1.3.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.
Files changed (43) hide show
  1. package/AI-INTEGRATION-GUIDE.md +16 -12
  2. package/CHANGELOG.md +82 -0
  3. package/LICENSE +21 -21
  4. package/README.md +104 -69
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -212
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -93
  9. package/lib/external.ps1 +87 -0
  10. package/lib/fs.ps1 +189 -180
  11. package/lib/log.ps1 +140 -140
  12. package/lib/safety.ps1 +467 -493
  13. package/lib/scan.ps1 +109 -100
  14. package/lib/ui.ps1 +275 -275
  15. package/modules/android_avd.ps1 +36 -36
  16. package/modules/app_caches.ps1 +58 -58
  17. package/modules/browsers.ps1 +33 -33
  18. package/modules/build_tools.ps1 +35 -35
  19. package/modules/crash_trap.ps1 +89 -89
  20. package/modules/disk_usage.ps1 +49 -49
  21. package/modules/docker.ps1 +93 -93
  22. package/modules/editors.ps1 +150 -150
  23. package/modules/globals_audit.ps1 +236 -236
  24. package/modules/health.ps1 +101 -101
  25. package/modules/installed_programs.ps1 +118 -118
  26. package/modules/menu.ps1 +63 -63
  27. package/modules/notify.ps1 +60 -60
  28. package/modules/orphaned_appdata.ps1 +189 -189
  29. package/modules/personal.ps1 +126 -126
  30. package/modules/pkg_managers.ps1 +73 -73
  31. package/modules/projects.ps1 +160 -160
  32. package/modules/release_helpers.ps1 +438 -438
  33. package/modules/reports.ps1 +212 -212
  34. package/modules/runner.ps1 +246 -239
  35. package/modules/self_test_contract.ps1 +195 -0
  36. package/modules/self_test_extra.ps1 +366 -360
  37. package/modules/startup_audit.ps1 +126 -126
  38. package/modules/system_admin.ps1 +220 -220
  39. package/modules/test_runners.ps1 +24 -24
  40. package/modules/walkthrough.ps1 +80 -79
  41. package/modules/windows_user.ps1 +118 -118
  42. package/package.json +3 -3
  43. package/windowsweep.ps1 +322 -316
package/lib/actions.ps1 CHANGED
@@ -1,212 +1,212 @@
1
- # actions.ps1 - the target-list engine every section drives, plus shared cache-layout helpers.
2
- # A section is mostly a declaration: New-Target rows (path + mode + guards) run by Invoke-TargetList.
3
-
4
- $Script:WS_CHROMIUM_PROFILE_CACHES = @('Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'GrShaderCache', 'ShaderCache', 'Service Worker\ScriptCache')
5
- $Script:WS_CHROMIUM_ROOT_CACHES = @('ShaderCache', 'GrShaderCache', 'GraphiteDawnCache', 'Crashpad\completed', 'Crashpad\reports', 'SwReporter')
6
- $Script:WS_ELECTRON_CACHES = @('Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'GrShaderCache', 'ShaderCache', 'Service Worker\CacheStorage', 'Service Worker\ScriptCache', 'Crashpad\completed', 'Crashpad\reports')
7
- $Script:WS_EDITOR_CACHES = @('Cache', 'CachedData', 'CachedProfilesData', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'Service Worker\CacheStorage', 'Service Worker\ScriptCache', 'Crashpad\completed', 'Crashpad\reports')
8
- $Script:WS_FIREFOX_CACHES = @('cache2', 'startupCache', 'thumbnails', 'jumpListCache', 'OfflineCache', 'shader-cache')
9
-
10
- function Get-ChromiumProfileDirs {
11
- <# .SYNOPSIS Profile folders of a Chromium "User Data" root. A root that itself holds Cache is its own profile (Opera, Electron). #>
12
- param([string] $Root)
13
- $out = @()
14
- if (-not (Test-DirPresent $Root)) { return $out }
15
- foreach ($e in (Get-ChildEntries $Root)) {
16
- if (-not ($e -is [IO.DirectoryInfo])) { continue }
17
- if ($e.Name -match '^(Default|Profile \d+|Guest Profile|System Profile)$') { $out += (Remove-LongPrefix $e.FullName) }
18
- }
19
- if ($out.Count -eq 0 -and (Test-DirPresent (Join-Path $Root 'Cache'))) { $out += (Get-FullPath $Root) }
20
- return $out
21
- }
22
-
23
- function Get-ChromiumCacheDirs {
24
- <# .SYNOPSIS Existing regenerable cache folders under a Chromium root (per profile + root-level). Never profile data. #>
25
- param([string] $Root)
26
- $out = @()
27
- foreach ($prof in (Get-ChromiumProfileDirs $Root)) {
28
- foreach ($sub in $Script:WS_CHROMIUM_PROFILE_CACHES) { $p = Join-Path $prof $sub; if (Test-DirPresent $p) { $out += $p } }
29
- }
30
- foreach ($sub in $Script:WS_CHROMIUM_ROOT_CACHES) { $p = Join-Path $Root $sub; if (Test-DirPresent $p) { $out += $p } }
31
- return @($out | Select-Object -Unique)
32
- }
33
-
34
- function Get-FirefoxCacheDirs {
35
- param([string] $ProfilesRoot)
36
- $out = @()
37
- if (-not (Test-DirPresent $ProfilesRoot)) { return $out }
38
- foreach ($prof in (Get-ChildEntries $ProfilesRoot)) {
39
- if (-not ($prof -is [IO.DirectoryInfo])) { continue }
40
- foreach ($sub in $Script:WS_FIREFOX_CACHES) { $p = Join-Path (Remove-LongPrefix $prof.FullName) $sub; if (Test-DirPresent $p) { $out += $p } }
41
- }
42
- return $out
43
- }
44
-
45
- function Get-SubdirCacheDirs {
46
- param([string] $Root, [string[]] $Subdirs)
47
- $out = @()
48
- if (-not (Test-DirPresent $Root)) { return $out }
49
- foreach ($sub in $Subdirs) { $p = Join-Path $Root $sub; if (Test-DirPresent $p) { $out += $p } }
50
- return $out
51
- }
52
-
53
- function Get-TargetCachePaths {
54
- <# .SYNOPSIS Concrete cache folders for the layout kinds (chromium | firefox | electron | editor). #>
55
- param([pscustomobject] $Target)
56
- $roots = @()
57
- if ($Target.Path -match '[\*\?]') {
58
- try { $roots = @(Get-Item -Path $Target.Path -Force -ErrorAction SilentlyContinue | Where-Object { $_ -is [IO.DirectoryInfo] } | ForEach-Object { $_.FullName }) } catch { $roots = @() }
59
- } elseif (Test-DirPresent $Target.Path) { $roots = @((Get-FullPath $Target.Path)) }
60
- $out = @()
61
- foreach ($r in $roots) {
62
- switch ($Target.Kind) {
63
- 'chromium' { $out += Get-ChromiumCacheDirs $r }
64
- 'firefox' { $out += Get-FirefoxCacheDirs $r }
65
- 'electron' { $out += Get-SubdirCacheDirs -Root $r -Subdirs $Script:WS_ELECTRON_CACHES }
66
- 'editor' { $out += Get-SubdirCacheDirs -Root $r -Subdirs $Script:WS_EDITOR_CACHES }
67
- }
68
- }
69
- return @($out)
70
- }
71
-
72
- function Show-TargetSizes {
73
- <# .SYNOPSIS Table of the given targets with their current size on disk. Returns total bytes. Skipped when --quiet. #>
74
- param([pscustomobject[]] $Targets)
75
- $total = [long]0
76
- if ($Script:WS.Quiet) { return $total }
77
- Write-UiLine (" {0,-44} {1,10} {2}" -f 'TARGET', 'ON DISK', 'PATH') 'White'
78
- foreach ($t in $Targets) {
79
- if ($t.Kind -eq 'cmd') { Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, '(cmd)', $t.Path) 'DarkGray'; continue }
80
- $paths = @(Resolve-TargetPaths $t)
81
- if ($paths.Count -eq 0) { Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, 'absent', $t.Path) 'DarkGray'; continue }
82
- $bytes = [long]0
83
- foreach ($p in $paths) { $bytes += Get-DirectoryBytes $p }
84
- $total += $bytes
85
- $shown = $t.Path
86
- if ($paths.Count -gt 1) { $shown = "$($t.Path) ($($paths.Count) folders)" }
87
- Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, (Format-Bytes $bytes), $shown) 'Gray'
88
- }
89
- Write-UiLine (" {0,-44} {1,10}" -f 'on disk now', (Format-Bytes $total)) 'Cyan'
90
- return $total
91
- }
92
-
93
- function Confirm-Section {
94
- <# .SYNOPSIS One confirmation per section. Dry-run needs none; the walkthrough's step prompt counts as one. #>
95
- param([string] $Prompt = 'Proceed with this section?', [string] $Default = 'y')
96
- $ws = $Script:WS
97
- if ($ws.DryRun) { Write-Note '[dry-run] estimating only - nothing is deleted'; return $true }
98
- if ($ws.SectionPreConfirmed) { return $true }
99
- return (Confirm-Ui -Prompt $Prompt -Default $Default)
100
- }
101
-
102
- function Test-KnownCacheLeaf {
103
- <# .SYNOPSIS Second guard for the layout kinds: only a folder whose leaf name is a declared cache name may be cleared. #>
104
- param([string] $Path)
105
- $known = @($Script:WS_CHROMIUM_PROFILE_CACHES + $Script:WS_CHROMIUM_ROOT_CACHES + $Script:WS_ELECTRON_CACHES + $Script:WS_EDITOR_CACHES + $Script:WS_FIREFOX_CACHES | ForEach-Object { [IO.Path]::GetFileName($_) })
106
- return ($known -contains [IO.Path]::GetFileName($Path))
107
- }
108
-
109
- function Invoke-TargetList {
110
- <# .SYNOPSIS Run every target with its mode under the dry-run, developer, purge and running-app policies. Returns bytes freed/estimated. #>
111
- param([pscustomobject[]] $Targets)
112
- $ws = $Script:WS
113
- $total = [long]0
114
- foreach ($t in $Targets) {
115
- if ($t.Kind -eq 'cmd') { continue }
116
- $paths = @(Resolve-TargetPaths $t)
117
- if ($paths.Count -eq 0) { Write-Info "$($t.Label) - absent"; continue }
118
- if (@($t.Guard).Count -gt 0 -and (Test-ProcessRunning $t.Guard)) {
119
- Write-Warn "$($t.Label) - skipped: $($t.Guard[0]) is running (close it and run this section again)"
120
- $ws.Hints += "Close $($t.Guard[0]) and re-run: windowsweep --only $($t.Section) --yes (to clear $($t.Label))"
121
- continue
122
- }
123
- $mode = $t.Mode
124
- if ($t.Kind -in 'chromium', 'firefox', 'electron', 'editor') {
125
- $mode = 'clear'
126
- # Second guard: a layout target may only ever clear a known cache folder, never a profile or app root.
127
- $paths = @($paths | Where-Object { if (Test-KnownCacheLeaf $_) { $true } else { Write-Err "REFUSE (not a known cache folder for a $($t.Kind) layout): $_"; $false } })
128
- }
129
- if ($ws.PurgeAll -and $mode -in 'prune', 'units') { $mode = 'clear' }
130
- if ($t.Dev -and $ws.Developer -eq $false -and $mode -in 'prune', 'units') { $mode = 'clear' }
131
- $days = $t.Days
132
- if ($days -le 0) { $days = $ws.Days }
133
- # A target that resolves to several folders (every browser profile, every cache name) labels each result
134
- # line with "parent\leaf", so ten "VS Code caches - cleared ..." lines become ten distinguishable ones.
135
- $multi = ($paths.Count -gt 1)
136
- foreach ($p in $paths) {
137
- $label = "$($t.Label)"
138
- if ($multi) { $label = "$($t.Label) [$(Split-Path -Leaf (Split-Path -Parent $p))\$([IO.Path]::GetFileName($p))]" }
139
- switch ($mode) {
140
- 'clear' { $r = Clear-DirectoryContents -Path $p -Within $p -Label $label; $total += $r.Freed }
141
- 'clear-old' { $r = Clear-DirectoryContents -Path $p -Within $p -Label $label -OlderThanDays $days; $total += $r.Freed }
142
- 'prune' { $r = Remove-StaleFiles -Root $p -Within $p -Days $days -Label $label; $total += $r.Freed }
143
- 'units' { $r = Remove-StaleUnits -Root $p -Within $p -Days $days -KeepNewest:$t.KeepNewest -DirectoriesOnly -GroupBy $t.GroupBy -ExcludeNames $t.ExcludeNames -Label $label; $total += $r.Freed }
144
- 'file' {
145
- $r = Remove-PathSafe -Path $p -Within (Split-Path -Parent $p) -Label "$($t.Label)"
146
- if ($r.Removed -and -not $ws.DryRun -and $r.Bytes -gt 0) { Write-Ok ("$($t.Label) - removed " + (Format-Bytes $r.Bytes)) }
147
- $total += $r.Bytes
148
- }
149
- default { Write-Warn "$($t.Label) - unknown mode '$mode'" }
150
- }
151
- }
152
- }
153
- return $total
154
- }
155
-
156
- function Remove-SupersededVersions {
157
- <# .SYNOPSIS Squirrel-style installs keep old app-x.y.z folders beside the current one; remove all but the highest version. #>
158
- param([string] $Root, [string] $Prefix = 'app-', [string[]] $Guard = @(), [string] $Label = '')
159
- if (-not (Test-DirPresent $Root)) { return [long]0 }
160
- if ($Guard.Count -gt 0 -and (Test-ProcessRunning $Guard)) { Write-Warn "$Label - superseded versions skipped: $($Guard[0]) is running"; return [long]0 }
161
- $versions = @()
162
- foreach ($e in (Get-ChildEntries $Root)) {
163
- if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
164
- if ($e.Name -like "$Prefix*") {
165
- $v = $null
166
- try { $v = [version]($e.Name.Substring($Prefix.Length) -replace '[^0-9.].*$', '') } catch { $v = $null }
167
- if ($v) { $versions += [pscustomobject]@{ Name = $e.Name; Path = (Remove-LongPrefix $e.FullName); Version = $v } }
168
- }
169
- }
170
- if ($versions.Count -lt 2) { return [long]0 }
171
- $newest = $versions | Sort-Object Version -Descending | Select-Object -First 1
172
- $freed = [long]0
173
- foreach ($v in $versions) {
174
- if ($v.Path -eq $newest.Path) { Write-Note "kept $($v.Name) (current version)"; continue }
175
- $r = Remove-PathSafe -Path $v.Path -Within $Root -Label $Label
176
- if ($r.Removed) { $freed += $r.Bytes; if (-not $Script:WS.DryRun) { Write-Ok ("$Label - removed superseded $($v.Name) (" + (Format-Bytes $r.Bytes) + ')') } }
177
- }
178
- return $freed
179
- }
180
-
181
- function ConvertFrom-SizeText {
182
- <# .SYNOPSIS "2.891GB" / "12.5 MB" / "20.48kB" -> bytes (docker prints decimal units). #>
183
- param([string] $Text)
184
- if ($Text -match '([\d.]+)\s*(B|kB|KB|MB|GB|TB)') {
185
- $n = [double]$Matches[1]
186
- switch ($Matches[2]) { 'kB' { return [long]($n * 1000) } 'KB' { return [long]($n * 1000) } 'MB' { return [long]($n * 1e6) } 'GB' { return [long]($n * 1e9) } 'TB' { return [long]($n * 1e12) } default { return [long]$n } }
187
- }
188
- return [long]0
189
- }
190
-
191
- function Get-NpmCacheDir {
192
- $fallback = Join-Path $Script:P.L 'npm-cache'
193
- if (-not (Test-CommandPresent 'npm')) { return $fallback }
194
- try {
195
- $out = & npm config get cache 2>$null
196
- $line = ($out | Select-Object -Last 1)
197
- if ($line -and (Test-DirPresent $line.Trim())) { return (Get-FullPath $line.Trim()) }
198
- } catch { $null = $_ }
199
- return $fallback
200
- }
201
-
202
- function Write-SectionIntro {
203
- <# .SYNOPSIS Print what a section does and the current strategy line. #>
204
- param([string[]] $Lines, [bool] $Dev = $false)
205
- $ws = $Script:WS
206
- foreach ($l in $Lines) { Write-Plain " $l" }
207
- if ($Dev) {
208
- if ($ws.PurgeAll) { Write-Warn 'strategy: FULL PURGE (--purge-all) - every cache target is emptied, including recently used files' }
209
- elseif ($ws.Developer -eq $false) { Write-Info 'strategy: developer mode OFF - these caches are cleared completely' }
210
- else { Write-Info "strategy: developer mode ON - only files idle $($ws.Days)+ days go; the newest version of each tool cache is kept" }
211
- }
212
- }
1
+ # actions.ps1 - the target-list engine every section drives, plus shared cache-layout helpers.
2
+ # A section is mostly a declaration: New-Target rows (path + mode + guards) run by Invoke-TargetList.
3
+
4
+ $Script:WS_CHROMIUM_PROFILE_CACHES = @('Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'GrShaderCache', 'ShaderCache', 'Service Worker\ScriptCache')
5
+ $Script:WS_CHROMIUM_ROOT_CACHES = @('ShaderCache', 'GrShaderCache', 'GraphiteDawnCache', 'Crashpad\completed', 'Crashpad\reports', 'SwReporter')
6
+ $Script:WS_ELECTRON_CACHES = @('Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'GrShaderCache', 'ShaderCache', 'Service Worker\CacheStorage', 'Service Worker\ScriptCache', 'Crashpad\completed', 'Crashpad\reports')
7
+ $Script:WS_EDITOR_CACHES = @('Cache', 'CachedData', 'CachedProfilesData', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'Service Worker\CacheStorage', 'Service Worker\ScriptCache', 'Crashpad\completed', 'Crashpad\reports')
8
+ $Script:WS_FIREFOX_CACHES = @('cache2', 'startupCache', 'thumbnails', 'jumpListCache', 'OfflineCache', 'shader-cache')
9
+
10
+ function Get-ChromiumProfileDirs {
11
+ <# .SYNOPSIS Profile folders of a Chromium "User Data" root. A root that itself holds Cache is its own profile (Opera, Electron). #>
12
+ param([string] $Root)
13
+ $out = @()
14
+ if (-not (Test-DirPresent $Root)) { return $out }
15
+ foreach ($e in (Get-ChildEntries $Root)) {
16
+ if (-not ($e -is [IO.DirectoryInfo])) { continue }
17
+ if ($e.Name -match '^(Default|Profile \d+|Guest Profile|System Profile)$') { $out += (Remove-LongPrefix $e.FullName) }
18
+ }
19
+ if ($out.Count -eq 0 -and (Test-DirPresent (Join-Path $Root 'Cache'))) { $out += (Get-FullPath $Root) }
20
+ return $out
21
+ }
22
+
23
+ function Get-ChromiumCacheDirs {
24
+ <# .SYNOPSIS Existing regenerable cache folders under a Chromium root (per profile + root-level). Never profile data. #>
25
+ param([string] $Root)
26
+ $out = @()
27
+ foreach ($prof in (Get-ChromiumProfileDirs $Root)) {
28
+ foreach ($sub in $Script:WS_CHROMIUM_PROFILE_CACHES) { $p = Join-Path $prof $sub; if (Test-DirPresent $p) { $out += $p } }
29
+ }
30
+ foreach ($sub in $Script:WS_CHROMIUM_ROOT_CACHES) { $p = Join-Path $Root $sub; if (Test-DirPresent $p) { $out += $p } }
31
+ return @($out | Select-Object -Unique)
32
+ }
33
+
34
+ function Get-FirefoxCacheDirs {
35
+ param([string] $ProfilesRoot)
36
+ $out = @()
37
+ if (-not (Test-DirPresent $ProfilesRoot)) { return $out }
38
+ foreach ($prof in (Get-ChildEntries $ProfilesRoot)) {
39
+ if (-not ($prof -is [IO.DirectoryInfo])) { continue }
40
+ foreach ($sub in $Script:WS_FIREFOX_CACHES) { $p = Join-Path (Remove-LongPrefix $prof.FullName) $sub; if (Test-DirPresent $p) { $out += $p } }
41
+ }
42
+ return $out
43
+ }
44
+
45
+ function Get-SubdirCacheDirs {
46
+ param([string] $Root, [string[]] $Subdirs)
47
+ $out = @()
48
+ if (-not (Test-DirPresent $Root)) { return $out }
49
+ foreach ($sub in $Subdirs) { $p = Join-Path $Root $sub; if (Test-DirPresent $p) { $out += $p } }
50
+ return $out
51
+ }
52
+
53
+ function Get-TargetCachePaths {
54
+ <# .SYNOPSIS Concrete cache folders for the layout kinds (chromium | firefox | electron | editor). #>
55
+ param([pscustomobject] $Target)
56
+ $roots = @()
57
+ if ($Target.Path -match '[\*\?]') {
58
+ try { $roots = @(Get-Item -Path $Target.Path -Force -ErrorAction SilentlyContinue | Where-Object { $_ -is [IO.DirectoryInfo] } | ForEach-Object { $_.FullName }) } catch { $roots = @() }
59
+ } elseif (Test-DirPresent $Target.Path) { $roots = @((Get-FullPath $Target.Path)) }
60
+ $out = @()
61
+ foreach ($r in $roots) {
62
+ switch ($Target.Kind) {
63
+ 'chromium' { $out += Get-ChromiumCacheDirs $r }
64
+ 'firefox' { $out += Get-FirefoxCacheDirs $r }
65
+ 'electron' { $out += Get-SubdirCacheDirs -Root $r -Subdirs $Script:WS_ELECTRON_CACHES }
66
+ 'editor' { $out += Get-SubdirCacheDirs -Root $r -Subdirs $Script:WS_EDITOR_CACHES }
67
+ }
68
+ }
69
+ return @($out)
70
+ }
71
+
72
+ function Show-TargetSizes {
73
+ <# .SYNOPSIS Table of the given targets with their current size on disk. Returns total bytes. Skipped when --quiet. #>
74
+ param([pscustomobject[]] $Targets)
75
+ $total = [long]0
76
+ if ($Script:WS.Quiet) { return $total }
77
+ Write-UiLine (" {0,-44} {1,10} {2}" -f 'TARGET', 'ON DISK', 'PATH') 'White'
78
+ foreach ($t in $Targets) {
79
+ if ($t.Kind -eq 'cmd') { Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, '(cmd)', $t.Path) 'DarkGray'; continue }
80
+ $paths = @(Resolve-TargetPaths $t)
81
+ if ($paths.Count -eq 0) { Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, 'absent', $t.Path) 'DarkGray'; continue }
82
+ $bytes = [long]0
83
+ foreach ($p in $paths) { $bytes += Get-DirectoryBytes $p }
84
+ $total += $bytes
85
+ $shown = $t.Path
86
+ if ($paths.Count -gt 1) { $shown = "$($t.Path) ($($paths.Count) folders)" }
87
+ Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, (Format-Bytes $bytes), $shown) 'Gray'
88
+ }
89
+ Write-UiLine (" {0,-44} {1,10}" -f 'on disk now', (Format-Bytes $total)) 'Cyan'
90
+ return $total
91
+ }
92
+
93
+ function Confirm-Section {
94
+ <# .SYNOPSIS One confirmation per section. Dry-run needs none; the walkthrough's step prompt counts as one. #>
95
+ param([string] $Prompt = 'Proceed with this section?', [string] $Default = 'y')
96
+ $ws = $Script:WS
97
+ if ($ws.DryRun) { Write-Note '[dry-run] estimating only - nothing is deleted'; return $true }
98
+ if ($ws.SectionPreConfirmed) { return $true }
99
+ return (Confirm-Ui -Prompt $Prompt -Default $Default)
100
+ }
101
+
102
+ function Test-KnownCacheLeaf {
103
+ <# .SYNOPSIS Second guard for the layout kinds: only a folder whose leaf name is a declared cache name may be cleared. #>
104
+ param([string] $Path)
105
+ $known = @($Script:WS_CHROMIUM_PROFILE_CACHES + $Script:WS_CHROMIUM_ROOT_CACHES + $Script:WS_ELECTRON_CACHES + $Script:WS_EDITOR_CACHES + $Script:WS_FIREFOX_CACHES | ForEach-Object { [IO.Path]::GetFileName($_) })
106
+ return ($known -contains [IO.Path]::GetFileName($Path))
107
+ }
108
+
109
+ function Invoke-TargetList {
110
+ <# .SYNOPSIS Run every target with its mode under the dry-run, developer, purge and running-app policies. Returns bytes freed/estimated. #>
111
+ param([pscustomobject[]] $Targets)
112
+ $ws = $Script:WS
113
+ $total = [long]0
114
+ foreach ($t in $Targets) {
115
+ if ($t.Kind -eq 'cmd') { continue }
116
+ $paths = @(Resolve-TargetPaths $t)
117
+ if ($paths.Count -eq 0) { Write-Info "$($t.Label) - absent"; continue }
118
+ if (@($t.Guard).Count -gt 0 -and (Test-ProcessRunning $t.Guard)) {
119
+ Write-Warn "$($t.Label) - skipped: $($t.Guard[0]) is running (close it and run this section again)"
120
+ $ws.Hints += "Close $($t.Guard[0]) and re-run: windowsweep --only $($t.Section) --yes (to clear $($t.Label))"
121
+ continue
122
+ }
123
+ $mode = $t.Mode
124
+ if ($t.Kind -in 'chromium', 'firefox', 'electron', 'editor') {
125
+ $mode = 'clear'
126
+ # Second guard: a layout target may only ever clear a known cache folder, never a profile or app root.
127
+ $paths = @($paths | Where-Object { if (Test-KnownCacheLeaf $_) { $true } else { Write-Err "REFUSE (not a known cache folder for a $($t.Kind) layout): $_"; $false } })
128
+ }
129
+ if ($ws.PurgeAll -and $mode -in 'prune', 'units') { $mode = 'clear' }
130
+ if ($t.Dev -and $ws.Developer -eq $false -and $mode -in 'prune', 'units') { $mode = 'clear' }
131
+ $days = $t.Days
132
+ if ($days -le 0) { $days = $ws.Days }
133
+ # A target that resolves to several folders (every browser profile, every cache name) labels each result
134
+ # line with "parent\leaf", so ten "VS Code caches - cleared ..." lines become ten distinguishable ones.
135
+ $multi = ($paths.Count -gt 1)
136
+ foreach ($p in $paths) {
137
+ $label = "$($t.Label)"
138
+ if ($multi) { $label = "$($t.Label) [$(Split-Path -Leaf (Split-Path -Parent $p))\$([IO.Path]::GetFileName($p))]" }
139
+ switch ($mode) {
140
+ 'clear' { $r = Clear-DirectoryContents -Path $p -Within $p -Label $label; $total += $r.Freed }
141
+ 'clear-old' { $r = Clear-DirectoryContents -Path $p -Within $p -Label $label -OlderThanDays $days; $total += $r.Freed }
142
+ 'prune' { $r = Remove-StaleFiles -Root $p -Within $p -Days $days -Label $label; $total += $r.Freed }
143
+ 'units' { $r = Remove-StaleUnits -Root $p -Within $p -Days $days -KeepNewest:$t.KeepNewest -DirectoriesOnly -GroupBy $t.GroupBy -ExcludeNames $t.ExcludeNames -Label $label; $total += $r.Freed }
144
+ 'file' {
145
+ $r = Remove-PathSafe -Path $p -Within (Split-Path -Parent $p) -Label "$($t.Label)"
146
+ if ($r.Removed -and -not $ws.DryRun -and $r.Bytes -gt 0) { Write-Ok ("$($t.Label) - removed " + (Format-Bytes $r.Bytes)) }
147
+ $total += $r.Bytes
148
+ }
149
+ default { Write-Warn "$($t.Label) - unknown mode '$mode'" }
150
+ }
151
+ }
152
+ }
153
+ return $total
154
+ }
155
+
156
+ function Remove-SupersededVersions {
157
+ <# .SYNOPSIS Squirrel-style installs keep old app-x.y.z folders beside the current one; remove all but the highest version. #>
158
+ param([string] $Root, [string] $Prefix = 'app-', [string[]] $Guard = @(), [string] $Label = '')
159
+ if (-not (Test-DirPresent $Root)) { return [long]0 }
160
+ if ($Guard.Count -gt 0 -and (Test-ProcessRunning $Guard)) { Write-Warn "$Label - superseded versions skipped: $($Guard[0]) is running"; return [long]0 }
161
+ $versions = @()
162
+ foreach ($e in (Get-ChildEntries $Root)) {
163
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
164
+ if ($e.Name -like "$Prefix*") {
165
+ $v = $null
166
+ try { $v = [version]($e.Name.Substring($Prefix.Length) -replace '[^0-9.].*$', '') } catch { $v = $null }
167
+ if ($v) { $versions += [pscustomobject]@{ Name = $e.Name; Path = (Remove-LongPrefix $e.FullName); Version = $v } }
168
+ }
169
+ }
170
+ if ($versions.Count -lt 2) { return [long]0 }
171
+ $newest = $versions | Sort-Object Version -Descending | Select-Object -First 1
172
+ $freed = [long]0
173
+ foreach ($v in $versions) {
174
+ if ($v.Path -eq $newest.Path) { Write-Note "kept $($v.Name) (current version)"; continue }
175
+ $r = Remove-PathSafe -Path $v.Path -Within $Root -Label $Label
176
+ if ($r.Removed) { $freed += $r.Bytes; if (-not $Script:WS.DryRun) { Write-Ok ("$Label - removed superseded $($v.Name) (" + (Format-Bytes $r.Bytes) + ')') } }
177
+ }
178
+ return $freed
179
+ }
180
+
181
+ function ConvertFrom-SizeText {
182
+ <# .SYNOPSIS "2.891GB" / "12.5 MB" / "20.48kB" -> bytes (docker prints decimal units). #>
183
+ param([string] $Text)
184
+ if ($Text -match '([\d.]+)\s*(B|kB|KB|MB|GB|TB)') {
185
+ $n = [double]$Matches[1]
186
+ switch ($Matches[2]) { 'kB' { return [long]($n * 1000) } 'KB' { return [long]($n * 1000) } 'MB' { return [long]($n * 1e6) } 'GB' { return [long]($n * 1e9) } 'TB' { return [long]($n * 1e12) } default { return [long]$n } }
187
+ }
188
+ return [long]0
189
+ }
190
+
191
+ function Get-NpmCacheDir {
192
+ $fallback = Join-Path $Script:P.L 'npm-cache'
193
+ if (-not (Test-CommandPresent 'npm')) { return $fallback }
194
+ try {
195
+ $out = & npm config get cache 2>$null
196
+ $line = ($out | Select-Object -Last 1)
197
+ if ($line -and (Test-DirPresent $line.Trim())) { return (Get-FullPath $line.Trim()) }
198
+ } catch { $null = $_ }
199
+ return $fallback
200
+ }
201
+
202
+ function Write-SectionIntro {
203
+ <# .SYNOPSIS Print what a section does and the current strategy line. #>
204
+ param([string[]] $Lines, [bool] $Dev = $false)
205
+ $ws = $Script:WS
206
+ foreach ($l in $Lines) { Write-Plain " $l" }
207
+ if ($Dev) {
208
+ if ($ws.PurgeAll) { Write-Warn 'strategy: FULL PURGE (--purge-all) - every cache target is emptied, including recently used files' }
209
+ elseif ($ws.Developer -eq $false) { Write-Info 'strategy: developer mode OFF - these caches are cleared completely' }
210
+ else { Write-Info "strategy: developer mode ON - only files idle $($ws.Days)+ days go; the newest version of each tool cache is kept" }
211
+ }
212
+ }