windowsweep 1.1.0 → 1.2.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 (41) hide show
  1. package/AI-INTEGRATION-GUIDE.md +15 -11
  2. package/CHANGELOG.md +56 -0
  3. package/LICENSE +21 -21
  4. package/README.md +70 -42
  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/fs.ps1 +189 -180
  10. package/lib/log.ps1 +140 -140
  11. package/lib/safety.ps1 +541 -493
  12. package/lib/scan.ps1 +109 -100
  13. package/lib/ui.ps1 +275 -275
  14. package/modules/android_avd.ps1 +36 -36
  15. package/modules/app_caches.ps1 +58 -58
  16. package/modules/browsers.ps1 +33 -33
  17. package/modules/build_tools.ps1 +35 -35
  18. package/modules/crash_trap.ps1 +89 -89
  19. package/modules/disk_usage.ps1 +49 -49
  20. package/modules/docker.ps1 +93 -93
  21. package/modules/editors.ps1 +150 -150
  22. package/modules/globals_audit.ps1 +236 -236
  23. package/modules/health.ps1 +101 -101
  24. package/modules/installed_programs.ps1 +118 -118
  25. package/modules/menu.ps1 +63 -63
  26. package/modules/notify.ps1 +60 -60
  27. package/modules/orphaned_appdata.ps1 +189 -189
  28. package/modules/personal.ps1 +126 -126
  29. package/modules/pkg_managers.ps1 +73 -73
  30. package/modules/projects.ps1 +160 -160
  31. package/modules/release_helpers.ps1 +438 -438
  32. package/modules/reports.ps1 +212 -212
  33. package/modules/runner.ps1 +242 -239
  34. package/modules/self_test_extra.ps1 +537 -360
  35. package/modules/startup_audit.ps1 +126 -126
  36. package/modules/system_admin.ps1 +220 -220
  37. package/modules/test_runners.ps1 +24 -24
  38. package/modules/walkthrough.ps1 +80 -79
  39. package/modules/windows_user.ps1 +118 -118
  40. package/package.json +2 -2
  41. package/windowsweep.ps1 +322 -316
@@ -1,438 +1,438 @@
1
- # release_helpers.ps1 - version, --list, --self-test, feedback, debug bundle, scheduled task, profile alias, data removal.
2
-
3
- function Show-Version {
4
- Write-Host ''
5
- Write-Host (" $Script:WS_NAME v$(Get-ToolVersion)")
6
- Write-Host (" $Script:WS_TAGLINE")
7
- Write-Host ''
8
- Write-Host (" Author: $Script:WS_AUTHOR")
9
- Write-Host (" Email: $Script:WS_EMAIL")
10
- Write-Host (" Web: $Script:WS_WEB")
11
- Write-Host (" LinkedIn: $Script:WS_LINKEDIN")
12
- Write-Host (" Source: $Script:WS_REPO")
13
- Write-Host (" License: $Script:WS_LICENSE")
14
- Write-Host ''
15
- Write-Host (" PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))")
16
- Write-Host (" Project root: $Script:WS_ROOT")
17
- Write-Host ''
18
- }
19
-
20
- function Show-SectionList {
21
- # --list --json prints the catalogue and nothing else: the human table below uses Write-Host, which would
22
- # break the one-line-on-stdout promise.
23
- if ($Script:WS.JsonMode) { [Console]::Out.WriteLine(((Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress)); return }
24
- Write-Host ''
25
- Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f '#', 'SECTION', 'TIER', 'ADMIN', 'BATCH')
26
- foreach ($s in $Script:WS_SECTIONS) {
27
- $adm = '-'
28
- if ($s.Admin) { $adm = 'yes' }
29
- Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f $s.Id, $s.Title, $s.Tier, $adm, $s.Batch)
30
- }
31
- Write-Host ''
32
- Write-Host (" safe batch (--all): " + ($Script:WS_SAFE_BATCH -join ',') + " (+" + ($Script:WS_SAFE_BATCH_ADMIN -join ',') + " when elevated)")
33
- foreach ($k in ($Script:WS_PROFILES.Keys | Sort-Object)) { Write-Host (" profile {0,-11} {1}" -f $k, ($Script:WS_PROFILES[$k] -join ',')) }
34
- Write-Host ' batch policy: safe = runs in --all | optin = --only/--profile with --yes | deep = also needs --i-understand-deep | interactive = never batch'
35
- Write-Host ''
36
- }
37
-
38
- # ---------------------------------------------------------------------------------------------
39
- # Self-test
40
- # ---------------------------------------------------------------------------------------------
41
-
42
- function Invoke-SelfTest {
43
- <# .SYNOPSIS Verify syntax, ASCII-only source, safety guards, dry-run, junctions and long paths. Exit 1 on any failure. #>
44
- $ws = $Script:WS
45
- $fails = 0
46
- $checks = 0
47
- Write-Box 'Self-test' 'Verify syntax, safety guards, dry-run guarantee, junction handling'
48
-
49
- Write-Section '[1] PowerShell'
50
- $checks++
51
- if ($PSVersionTable.PSVersion.Major -ge 5) { Write-Ok "PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))" } else { Write-Err "PowerShell $($PSVersionTable.PSVersion) - 5.1+ required"; $fails++ }
52
-
53
- Write-Section '[2] Optional tools'
54
- foreach ($c in @('robocopy.exe', 'cleanmgr.exe', 'Dism.exe', 'powercfg.exe', 'wevtutil.exe', 'diskpart.exe', 'wsl.exe', 'docker', 'npm', 'yarn', 'pnpm', 'pip', 'uv', 'composer', 'dotnet', 'cargo', 'go')) {
55
- if (Test-CommandPresent $c) { Write-Ok $c } else { Write-Note "$c (optional - the steps that use it are skipped)" }
56
- }
57
-
58
- Write-Section '[3] Script syntax'
59
- $files = @(Get-Item -LiteralPath $ws.ScriptPath) + @(Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'lib'), (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)
60
- foreach ($f in $files) {
61
- $checks++
62
- $tokens = $null; $errors = $null
63
- $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors)
64
- if ($errors.Count -eq 0) { Write-Ok $f.Name } else { Write-Err "$($f.Name): $($errors[0].Message)"; $fails++ }
65
- }
66
-
67
- Write-Section '[4] ASCII-only source'
68
- $srcFiles = @($files) + @(Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'bin') -Filter '*.js' -File -ErrorAction SilentlyContinue)
69
- foreach ($f in $srcFiles) {
70
- $checks++
71
- $bad = 0
72
- foreach ($b in [IO.File]::ReadAllBytes($f.FullName)) { if ($b -gt 127) { $bad++; break } }
73
- if ($bad -eq 0) { Write-Ok $f.Name } else { Write-Err "$($f.Name): $bad non-ASCII byte(s) - PowerShell 5.1 would misread this file"; $fails++ }
74
- }
75
-
76
- Write-Section '[5] Safety guards'
77
- $U = $env:USERPROFILE; $L = $env:LOCALAPPDATA
78
- $mustRefuse = @("$env:SystemDrive\", $env:SystemRoot, "$env:SystemRoot\System32", $env:ProgramFiles, $U, "$U\Documents", "$U\Pictures\holiday", "$U\.ssh\id_ed25519", "$U\.claude\settings.json", "$env:APPDATA\npm", "$env:SystemRoot\Prefetch", "$env:SystemRoot\Installer", "$L\Temp\..\..\Roaming", '\\server\share\x', "$L\Google\Chrome\User Data\Default\Local Storage\leveldb", "$L\Google\Chrome\User Data\Default", "$L\Google\Chrome\User Data\Profile 12", "$L\Packages\Some.App_abc\LocalState\data", "$env:APPDATA\Code\User\globalStorage\state.vscdb", "$U\.cache\claude\x", "$env:SystemDrive\hiberfil.sys", $ws.Home)
79
- foreach ($p in $mustRefuse) {
80
- $checks++
81
- $why = Get-ProtectionReason $p
82
- if ($why) { Write-Ok "refuses $p ($why)" } else { Write-Err "DANGEROUS: not protected: $p"; $fails++ }
83
- }
84
- $mustAllow = @("$L\npm-cache\_cacache", "$L\Temp\some-tmp-file.tmp", "$L\Google\Chrome\User Data\Default\Cache\Cache_Data", "$L\Yarn\Cache\v6", "$U\.gradle\caches\modules-2", "$L\Android\Sdk\.temp\x", "$env:APPDATA\Code\CachedExtensionVSIXs", "$U\.vscode\extensions\some.ext-1.0.0")
85
- foreach ($p in $mustAllow) {
86
- $checks++
87
- $why = Get-ProtectionReason $p
88
- if (-not $why) { Write-Ok "allows $p" } else { Write-Err "would block a legitimate target: $p ($why)"; $fails++ }
89
- }
90
- $checks++
91
- if ((Test-PathWithin -Path "$L\Temp\a\b" -Within "$L\Temp") -and -not (Test-PathWithin -Path "$L\Tempx\a" -Within "$L\Temp") -and (Test-PathWithin -Path "$L\Temp" -Within "$L\Temp")) { Write-Ok 'Test-PathWithin: inside / sibling-prefix / equal' } else { Write-Err 'Test-PathWithin is wrong'; $fails++ }
92
-
93
- Write-Section '[6] Declared targets are never protected'
94
- $targets = @(Get-AllTargets | Where-Object { $_.Kind -in 'dir', 'file' })
95
- $checks++
96
- $leaked = 0
97
- foreach ($t in $targets) {
98
- $why = Get-ProtectionReason $t.Path
99
- if ($why) { Write-Err "target [$($t.Section)] $($t.Label) is inside a protected path: $($t.Path) ($why)"; $leaked++ }
100
- }
101
- if ($leaked -eq 0) { Write-Ok "$($targets.Count) declared targets, none inside a protected path" } else { $fails += $leaked }
102
-
103
- Write-Section '[7] Fixture: junctions, dry-run, stale prune, keep-newest, long paths'
104
- $fx = Join-Path $ws.Home ('selftest-' + [guid]::NewGuid().ToString('N'))
105
- $savedDry = $ws.DryRun; $savedYes = $ws.Yes; $savedQuiet = $ws.Quiet
106
- # Helper output (including the refusals we deliberately provoke) is muted; only the verdict lines print.
107
- $mute = { $Script:WS.Mute = $true }
108
- $unmute = { $Script:WS.Mute = $false }
109
- try {
110
- New-Item -ItemType Directory -Force -Path $fx | Out-Null
111
- $ws.AllowOwnData = $true
112
- & $mute
113
- $target = Join-Path $fx 'target'; New-Item -ItemType Directory -Force -Path $target | Out-Null
114
- $sentinel = Join-Path $target 'sentinel.txt'; Set-Content -LiteralPath $sentinel -Value 'keep me'
115
- $cache = Join-Path $fx 'cache'; New-Item -ItemType Directory -Force -Path $cache | Out-Null
116
- New-Item -ItemType Junction -Path (Join-Path $cache 'link') -Target $target | Out-Null
117
- Set-Content -LiteralPath (Join-Path $cache 'junk.txt') -Value 'junk'
118
- # 7a: removing a junction removes the link only
119
- $checks++
120
- $ws.DryRun = $false
121
- $r = Remove-PathSafe -Path (Join-Path $cache 'link') -Within $cache
122
- & $unmute
123
- if ($r.Removed -and (Test-Path -LiteralPath $sentinel) -and -not (Test-Path -LiteralPath (Join-Path $cache 'link'))) { Write-Ok 'junction removed as a link; target sentinel survives' } else { Write-Err 'junction handling is wrong'; $fails++ }
124
- & $mute
125
- # 7b: a junction nested inside a tree being removed is not followed
126
- $checks++
127
- $tree = Join-Path $fx 'tree'; New-Item -ItemType Directory -Force -Path (Join-Path $tree 'sub') | Out-Null
128
- New-Item -ItemType Junction -Path (Join-Path $tree 'sub\jl') -Target $target | Out-Null
129
- Set-Content -LiteralPath (Join-Path $tree 'sub\file.txt') -Value 'x'
130
- $r = Remove-PathSafe -Path $tree -Within $fx
131
- & $unmute
132
- if ($r.Removed -and (Test-Path -LiteralPath $sentinel) -and -not (Test-Path -LiteralPath $tree)) { Write-Ok 'nested junction not followed; tree removed; sentinel survives' } else { Write-Err 'nested junction handling is wrong'; $fails++ }
133
- & $mute
134
- # 7c: dry-run changes nothing
135
- $checks++
136
- $old = Join-Path $fx 'old'; New-Item -ItemType Directory -Force -Path (Join-Path $old 'deep') | Out-Null
137
- $oldFile = Join-Path $old 'deep\old.bin'; [IO.File]::WriteAllBytes($oldFile, (New-Object byte[] 4096))
138
- $freshFile = Join-Path $old 'fresh.bin'; [IO.File]::WriteAllBytes($freshFile, (New-Object byte[] 2048))
139
- $ancient = (Get-Date).AddDays(-400)
140
- foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $oldFile -Name $ts -Value $ancient }
141
- # Files carry the evidence (name, size, write time); directory timestamps are excluded because NTFS
142
- # updates a folder's own LastWriteTime lazily, which made an earlier version of this check flap.
143
- $snapshot = { param($root) ((Get-ChildItem -LiteralPath $root -Recurse -Force -File | ForEach-Object { "$($_.FullName)|$($_.Length)|$($_.LastWriteTimeUtc.Ticks)" }) + (Get-ChildItem -LiteralPath $root -Recurse -Force -Directory | ForEach-Object { "D|$($_.FullName)" })) -join "`n" }
144
- $snapBefore = & $snapshot $old
145
- $ws.DryRun = $true
146
- $estBefore = $ws.TotalEstimated
147
- $r = Remove-StaleFiles -Root $old -Within $fx -Days 100 -Label 'fixture'
148
- $snapAfter = & $snapshot $old
149
- $ws.DryRun = $false
150
- & $unmute
151
- if ($snapBefore -eq $snapAfter -and $r.Freed -eq 4096 -and ($ws.TotalEstimated - $estBefore) -eq 4096) { Write-Ok 'dry-run changed nothing and estimated exactly the stale 4096 bytes' } else { Write-Err "dry-run guarantee broken (freed=$($r.Freed), tree changed=$($snapBefore -ne $snapAfter))"; $fails++ }
152
- & $mute
153
- # 7d: real stale prune removes only the old file and sweeps the empty dir
154
- $checks++
155
- $r = Remove-StaleFiles -Root $old -Within $fx -Days 100 -Label 'fixture'
156
- & $unmute
157
- if ((-not (Test-Path -LiteralPath $oldFile)) -and (Test-Path -LiteralPath $freshFile) -and (-not (Test-Path -LiteralPath (Join-Path $old 'deep'))) -and $r.Freed -eq 4096) { Write-Ok 'stale prune removed the idle file, kept the fresh one, swept the empty directory' } else { Write-Err 'stale prune behaved wrongly'; $fails++ }
158
- & $mute
159
- # 7e: keep-newest keeps the freshest unit even when every unit is idle
160
- $checks++
161
- $units = Join-Path $fx 'units'
162
- foreach ($v in '1.0.0', '2.0.0') { New-Item -ItemType Directory -Force -Path (Join-Path $units $v) | Out-Null; Set-Content -LiteralPath (Join-Path $units "$v\bin.txt") -Value $v }
163
- $t1 = (Get-Date).AddDays(-300); $t2 = (Get-Date).AddDays(-200)
164
- foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath (Join-Path $units '1.0.0\bin.txt') -Name $ts -Value $t1; Set-ItemProperty -LiteralPath (Join-Path $units '2.0.0\bin.txt') -Name $ts -Value $t2 }
165
- $r = Remove-StaleUnits -Root $units -Within $fx -Days 100 -KeepNewest -DirectoriesOnly -Label 'fixture units'
166
- & $unmute
167
- if ((Test-Path -LiteralPath (Join-Path $units '2.0.0')) -and -not (Test-Path -LiteralPath (Join-Path $units '1.0.0')) -and $r.Pruned -eq 1 -and $r.Kept -eq 1) { Write-Ok 'keep-newest kept 2.0.0 (200d idle) and pruned 1.0.0 (300d idle)' } else { Write-Err 'keep-newest behaved wrongly'; $fails++ }
168
- & $mute
169
- # 7f: long path
170
- $checks++
171
- $deep = Join-Path $fx 'long'; $p = $deep
172
- for ($i = 0; $i -lt 12; $i++) { $p = "$p\segment_$('x' * 20)_$i" }
173
- [IO.Directory]::CreateDirectory((Get-LongPath $p)) | Out-Null
174
- [IO.File]::WriteAllText((Get-LongPath (Join-Path $p 'deep.txt')), 'x')
175
- $r = Remove-PathSafe -Path $deep -Within $fx
176
- & $unmute
177
- if ($r.Removed -and -not (Test-DirPresent $deep) -and $p.Length -gt 260) { Write-Ok "long path ($($p.Length) chars) removed" } else { Write-Err 'long-path removal failed'; $fails++ }
178
- & $mute
179
- # 7g: refusals inside the fixture (the refusal messages themselves are muted; the verdicts print)
180
- $checks++
181
- $r = Remove-PathSafe -Path (Join-Path $fx 'target') -Within (Join-Path $fx 'cache')
182
- & $unmute
183
- if (-not $r.Removed -and $r.Reason -like 'outside declared root*' -and (Test-Path -LiteralPath $sentinel)) { Write-Ok 'a path outside its declared root is refused' } else { Write-Err 'Within guard failed'; $fails++ }
184
- & $mute
185
- $checks++
186
- $r = Remove-PathSafe -Path $fx -Within $fx
187
- & $unmute
188
- if (-not $r.Removed -and (Test-Path -LiteralPath $fx)) { Write-Ok 'a root never deletes itself' } else { Write-Err 'root self-delete guard failed'; $fails++ }
189
- $checks++
190
- & $mute
191
- $r = Remove-PathSafe -Path (Join-Path $env:USERPROFILE 'Documents') -Within $env:USERPROFILE
192
- & $unmute
193
- if (-not $r.Removed -and $r.Reason -like 'inside protected*') { Write-Ok 'the chokepoint refuses Documents even when a caller declares the profile as its root' } else { Write-Err 'protected-path refusal failed at the chokepoint'; $fails++ }
194
- # 7h: editor extension leftovers - the manifest is an ARRAY (the ConvertFrom-Json shape that once collapsed 16 entries into 1)
195
- $checks++
196
- $extRoot = Join-Path $fx 'extensions'
197
- foreach ($d in 'pub.alpha-1.0.0', 'pub.alpha-0.9.0', 'pub.beta-2.0.0-win32-x64', 'pub.gamma-1.0.0', 'pub.delta-3.0.0') { New-Item -ItemType Directory -Force -Path (Join-Path $extRoot $d) | Out-Null; Set-Content -LiteralPath (Join-Path $extRoot "$d\package.json") -Value '{}' }
198
- Set-Content -LiteralPath (Join-Path $extRoot 'extensions.json') -Value '[{"identifier":{"id":"pub.alpha"},"version":"1.0.0","relativeLocation":"pub.alpha-1.0.0"},{"identifier":{"id":"pub.beta"},"version":"2.0.0","relativeLocation":"pub.beta-2.0.0-win32-x64"},{"identifier":{"id":"pub.delta"},"version":"3.0.0","relativeLocation":"pub.delta-3.0.0"}]'
199
- Set-Content -LiteralPath (Join-Path $extRoot '.obsolete') -Value '{"pub.gamma-1.0.0":true}'
200
- $folders = @()
201
- foreach ($e in @(Get-ChildEntries $extRoot)) { if (($e -is [IO.DirectoryInfo]) -and $e.Name -match '^(.+?)-(\d+\.\d+\.\d+)([^\\]*)$') { $folders += [pscustomobject]@{ Name = $e.Name; Id = $Matches[1]; Path = (Remove-LongPrefix $e.FullName); Version = [version]$Matches[2] } } }
202
- $plan = Get-SupersededExtensionPlan -Root $extRoot -Folders $folders
203
- $names = @($plan.Victims | ForEach-Object { $_.Name } | Sort-Object)
204
- if (($names -join ',') -eq 'pub.alpha-0.9.0,pub.gamma-1.0.0') { Write-Ok 'extension leftovers: superseded alpha-0.9.0 + obsolete gamma go; the 3 installed folders stay' } else { Write-Err "extension leftover plan wrong: [$($names -join ',')]"; $fails++ }
205
- } catch {
206
- & $unmute
207
- Write-Err "fixture test crashed: $($_.Exception.Message)"; $fails++
208
- } finally {
209
- & $unmute
210
- $ws.DryRun = $savedDry; $ws.Yes = $savedYes; $ws.Quiet = $savedQuiet
211
- try { if (Test-Path -LiteralPath $fx) { [IO.Directory]::Delete((Get-LongPath $fx), $true) } } catch { $null = $_ }
212
- $ws.AllowOwnData = $false
213
- }
214
-
215
- Write-Section '[8] Pure helpers'
216
- $checks++
217
- $sel = ConvertTo-IndexList -Text '1,3,5-7,9-8,99' -Total 10
218
- if (($sel -join ',') -eq '1,3,5,6,7,8,9') { Write-Ok "multi-select parser: '1,3,5-7,9-8,99' -> $($sel -join ',')" } else { Write-Err "multi-select parser wrong: $($sel -join ',')"; $fails++ }
219
- $checks++
220
- if ((Format-Bytes 0) -eq '0 B' -and (Format-Bytes 1536) -eq '1.5 KB' -and (Format-Bytes 1073741824) -eq '1.0 GB') { Write-Ok 'Format-Bytes' } else { Write-Err "Format-Bytes wrong: $(Format-Bytes 1536)"; $fails++ }
221
-
222
- Write-Section '[9] No network code'
223
- $checks++
224
- $needles = @(('Invoke-Web' + 'Request'), ('Invoke-Rest' + 'Method'), ('Net.Web' + 'Client'), ('Http' + 'Client'), ('Sockets.' + 'TcpClient'), ('curl' + '.exe'), ('wget' + ' '))
225
- $hits = 0
226
- foreach ($f in $srcFiles) {
227
- $text = [IO.File]::ReadAllText($f.FullName)
228
- foreach ($n in $needles) { if ($text.IndexOf($n, [StringComparison]::OrdinalIgnoreCase) -ge 0) { Write-Err "network call in $($f.Name): $n"; $hits++ } }
229
- }
230
- if ($hits -eq 0) { Write-Ok 'no HTTP or socket calls in the source' } else { $fails += $hits }
231
-
232
- Write-Section '[10] Version parity'
233
- $checks++
234
- $verFile = Join-Path $Script:WS_ROOT 'VERSION'
235
- $pkgFile = Join-Path $Script:WS_ROOT 'package.json'
236
- $vFile = ''; $vPkg = ''
237
- if (Test-Path -LiteralPath $verFile) { $vFile = (Get-Content -LiteralPath $verFile -Raw).Trim() }
238
- if (Test-Path -LiteralPath $pkgFile) { try { $vPkg = (Get-Content -LiteralPath $pkgFile -Raw | ConvertFrom-Json).version } catch { $vPkg = '?' } }
239
- if ($vFile -eq $Script:WS_VERSION_FALLBACK -and $vPkg -eq $Script:WS_VERSION_FALLBACK) { Write-Ok "VERSION, package.json and lib/constants.ps1 all say $vPkg" } else { Write-Err "version mismatch: VERSION='$vFile' package.json='$vPkg' constants='$Script:WS_VERSION_FALLBACK'"; $fails++ }
240
-
241
- Write-Section '[11] Output paths'
242
- foreach ($d in @($ws.LogsDir, $ws.ReportsDir)) {
243
- $checks++
244
- try { $probe = Join-Path $d ('.write-test-' + [guid]::NewGuid().ToString('N')); Set-Content -LiteralPath $probe -Value 'x'; Remove-Item -LiteralPath $probe -Force; Write-Ok "$d writable" } catch { Write-Err "$d not writable"; $fails++ }
245
- }
246
-
247
- if (Get-Command Invoke-SelfTestExtra -ErrorAction SilentlyContinue) {
248
- $extra = Invoke-SelfTestExtra
249
- $checks += [int]$extra.Checks
250
- $fails += [int]$extra.Fails
251
- }
252
-
253
- Write-Separator
254
- if ($fails -eq 0) { Write-Ok "all $checks checks passed - $Script:WS_NAME is ready"; return $Script:WS_EXIT_OK }
255
- Write-Err "$fails of $checks checks FAILED"
256
- return $Script:WS_EXIT_FAIL
257
- }
258
-
259
- # ---------------------------------------------------------------------------------------------
260
- # Feedback, bug reports, debug bundle
261
- # ---------------------------------------------------------------------------------------------
262
-
263
- function Show-Feedback {
264
- Write-Box 'Send feedback / report a bug' 'All offline - nothing leaves your machine unless you send it'
265
- Write-Plain " Issues: $Script:WS_ISSUES"
266
- Write-Plain " Email: $Script:WS_EMAIL"
267
- Write-Plain " Web: $Script:WS_WEB"
268
- Write-UiLine '' 'Gray'
269
- Write-Plain ' Please include: Windows version, PowerShell version ($PSVersionTable.PSVersion),'
270
- Write-Plain " $Script:WS_NAME version (currently v$(Get-ToolVersion)), the exact command, what you expected,"
271
- Write-Plain ' what happened, and the log from --debug-bundle (review it first: it holds paths from your machine).'
272
- Write-UiLine '' 'Gray'
273
- Write-Plain ' windowsweep --report-issue opens a pre-filled GitHub issue in your browser'
274
- Write-Plain ' windowsweep --debug-bundle zips the latest log + report for attaching'
275
- Write-UiLine '' 'Gray'
276
- Write-Note 'Privacy: windowsweep makes no network calls. Logs and reports are written only under ~\.windowsweep.'
277
- }
278
-
279
- function Invoke-ReportIssue {
280
- <# .SYNOPSIS Build a GitHub new-issue URL with environment facts and open it in the default browser after confirming. #>
281
- $ws = $Script:WS
282
- $title = "[bug] $Script:WS_NAME v$(Get-ToolVersion): "
283
- $body = @(
284
- '**What happened**', '', '', '**What you expected**', '', '', '**Command you ran**', '', '```', 'windowsweep ', '```', '',
285
- '**Environment (auto-filled, review before submitting)**',
286
- "- OS: $($ws.OsCaption) ($([Environment]::OSVersion.Version))",
287
- "- PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))",
288
- "- $Script:WS_NAME`: v$(Get-ToolVersion) via $($ws.Launcher)",
289
- "- Elevated: $($ws.IsAdmin)", '',
290
- '**Log excerpt** (from `windowsweep --debug-bundle`, paths redacted as you see fit)', '', '```', '', '```'
291
- ) -join "`n"
292
- $url = "$Script:WS_ISSUES/new?title=$([Uri]::EscapeDataString($title))&labels=bug&body=$([Uri]::EscapeDataString($body))"
293
- Write-Box 'Report an issue' 'A pre-filled GitHub issue opens in your browser; nothing is submitted until you click Submit'
294
- Write-Note 'Environment facts included: OS build, PowerShell version, tool version, launcher, elevation. No paths, no logs.'
295
- if (Confirm-Ui -Prompt 'Open the pre-filled issue in your browser now?' -Default 'y') {
296
- try { Start-Process $url | Out-Null; Write-Ok 'opened - review, edit, then submit' } catch { Write-Warn "could not open a browser. URL:"; Write-Plain $url }
297
- } else {
298
- Write-Plain ' URL (copy it into a browser):'
299
- Write-Plain " $url"
300
- }
301
- }
302
-
303
- function New-DebugBundle {
304
- Write-Box 'Debug bundle' 'Latest log + latest report + system manifest, zipped locally'
305
- $zip = New-BundleZip -Prefix 'debug-bundle' -Manifest (New-SystemManifest -Kind 'debug bundle')
306
- if ($zip) {
307
- Write-Ok "bundle created: $zip"
308
- Write-Warn 'Review the bundle before sharing - it contains paths from your machine and a cache-size snapshot.'
309
- Write-Note "Attach it to an issue at $Script:WS_ISSUES"
310
- }
311
- }
312
-
313
- # ---------------------------------------------------------------------------------------------
314
- # Scheduled task, profile alias, data removal, history pruning
315
- # ---------------------------------------------------------------------------------------------
316
-
317
- $Script:WS_TASK_NAME = 'windowsweep weekly safe cleanup'
318
-
319
- function Get-LaunchCommand {
320
- <# .SYNOPSIS The command the scheduled task / alias should run: the launcher that started us, or the script. #>
321
- $ws = $Script:WS
322
- if ($ws.Launcher -eq 'node' -and -not $ws.Npx) {
323
- $cmd = Get-Command windowsweep -ErrorAction SilentlyContinue
324
- if ($cmd) { return [pscustomobject]@{ Exe = $cmd.Source; Args = @() } }
325
- }
326
- return [pscustomobject]@{ Exe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; Args = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', "`"$($ws.ScriptPath)`"") }
327
- }
328
-
329
- function Test-NpxInstallerRefusal {
330
- <# .SYNOPSIS Under npx the package lives in a cache npm evicts; a task or alias pointing there would break later. #>
331
- param([string] $Flag)
332
- if (-not $Script:WS.Npx) { return $false }
333
- Write-Err "$Flag is refused under npx: it would point at the npx cache, which npm evicts."
334
- Write-Plain ' Install once, then register from the global command:'
335
- Write-Plain ' npm install -g windowsweep'
336
- Write-Plain " windowsweep $Flag"
337
- $Script:WS.ExitCode = $Script:WS_EXIT_REFUSED
338
- return $true
339
- }
340
-
341
- function Install-WeeklyTask {
342
- Write-Box 'Install weekly Scheduled Task' "$Script:WS_TASK_NAME - Sundays 03:00, safe batch, no prompts"
343
- if (Test-NpxInstallerRefusal '--install-task') { return }
344
- if (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue) { Write-Info 'task already exists'; return }
345
- $lc = Get-LaunchCommand
346
- # --notify is part of the task action: an unattended Sunday run that says nothing is a run nobody sees.
347
- $argLine = (@($lc.Args) + @('--all', '--yes', '--quiet', '--no-color', '--notify')) -join ' '
348
- Write-Info "action: $($lc.Exe) $argLine"
349
- if ($Script:WS.DryRun) { Write-DryRun 'would register the task'; return }
350
- if (-not (Confirm-Ui -Prompt 'Register this task for your user account?' -Default 'y')) { Write-Info 'skipped'; return }
351
- try {
352
- $action = New-ScheduledTaskAction -Execute $lc.Exe -Argument $argLine
353
- $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 3am
354
- $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfIdle:$false -ExecutionTimeLimit (New-TimeSpan -Hours 3)
355
- Register-ScheduledTask -TaskName $Script:WS_TASK_NAME -Action $action -Trigger $trigger -Settings $settings -Description "$Script:WS_NAME safe batch (--all --yes). $Script:WS_REPO" -ErrorAction Stop | Out-Null
356
- Write-Ok "registered '$Script:WS_TASK_NAME' (runs as your user, Sundays 03:00; catches up if the PC was off)"
357
- } catch { Write-Err "could not register the task: $($_.Exception.Message)" }
358
- }
359
-
360
- function Uninstall-WeeklyTask {
361
- Write-Box 'Remove weekly Scheduled Task'
362
- if (-not (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue)) { Write-Info 'no task found'; return }
363
- if ($Script:WS.DryRun) { Write-DryRun 'would unregister the task'; return }
364
- if (Confirm-Ui -Prompt "Remove '$Script:WS_TASK_NAME'?" -Default 'y') {
365
- try { Unregister-ScheduledTask -TaskName $Script:WS_TASK_NAME -Confirm:$false -ErrorAction Stop; Write-Ok 'task removed' } catch { Write-Err "could not remove the task: $($_.Exception.Message)" }
366
- }
367
- }
368
-
369
- $Script:WS_ALIAS_MARK = '# windowsweep alias'
370
-
371
- function Install-ProfileAlias {
372
- Write-Box 'Install profile alias' "Adds a 'cleanup' function to your PowerShell profile"
373
- if (Test-NpxInstallerRefusal '--install-alias') { return }
374
- $profilePath = $PROFILE.CurrentUserAllHosts
375
- $lc = Get-LaunchCommand
376
- $line = "function cleanup { & `"$($lc.Exe)`" $($lc.Args -join ' ') @args }"
377
- if ((Test-Path -LiteralPath $profilePath) -and (Select-String -LiteralPath $profilePath -Pattern ([regex]::Escape($Script:WS_ALIAS_MARK)) -Quiet)) { Write-Info "alias already present in $profilePath"; return }
378
- Write-Info "will append to $profilePath"
379
- Write-Note $line
380
- if ($Script:WS.DryRun) { Write-DryRun 'would append the alias'; return }
381
- if (-not (Confirm-Ui -Prompt 'Add it?' -Default 'y')) { Write-Info 'skipped'; return }
382
- try {
383
- $dir = Split-Path -Parent $profilePath
384
- if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
385
- Add-Content -LiteralPath $profilePath -Value @('', $Script:WS_ALIAS_MARK, $line)
386
- Write-Ok "added. Open a new PowerShell window (or run '. `$PROFILE.CurrentUserAllHosts'), then type: cleanup"
387
- } catch { Write-Err "could not write the profile: $($_.Exception.Message)" }
388
- }
389
-
390
- function Uninstall-ProfileAlias {
391
- Write-Box 'Remove profile alias'
392
- $profilePath = $PROFILE.CurrentUserAllHosts
393
- if (-not (Test-Path -LiteralPath $profilePath)) { Write-Info 'no profile file'; return }
394
- $lines = Get-Content -LiteralPath $profilePath
395
- if (-not ($lines | Where-Object { $_ -eq $Script:WS_ALIAS_MARK })) { Write-Info 'alias not present'; return }
396
- if ($Script:WS.DryRun) { Write-DryRun 'would remove the alias block'; return }
397
- if (-not (Confirm-Ui -Prompt "Remove the windowsweep alias from $profilePath ?" -Default 'y')) { Write-Info 'skipped'; return }
398
- $out = @(); $skipNext = $false
399
- foreach ($l in $lines) {
400
- if ($l -eq $Script:WS_ALIAS_MARK) { $skipNext = $true; continue }
401
- if ($skipNext) { $skipNext = $false; if ($l -like 'function cleanup*') { continue } }
402
- $out += $l
403
- }
404
- Set-Content -LiteralPath $profilePath -Value $out
405
- Write-Ok 'alias removed'
406
- }
407
-
408
- function Remove-ToolData {
409
- $ws = $Script:WS
410
- Write-Box 'Remove windowsweep data' $ws.Home
411
- if (-not (Test-Path -LiteralPath $ws.Home)) { Write-Info 'nothing to remove'; return }
412
- $bytes = Get-DirectoryBytes $ws.Home
413
- Write-Info ("$($ws.Home) holds " + (Format-Bytes $bytes) + ' of logs, reports, bundles and config')
414
- if ($ws.DryRun) { Write-DryRun "would remove $($ws.Home)"; return }
415
- if (-not (Confirm-Ui -Prompt 'Delete it all (logs, reports, your developer answer)?' -Default 'n' -NoAutoYes)) { Write-Info 'skipped'; return }
416
- $ws.LogFile = $null
417
- try { [IO.Directory]::Delete((Get-LongPath $ws.Home), $true); Write-Ok 'removed' } catch { Write-Err "could not remove: $($_.Exception.Message)" }
418
- }
419
-
420
- function Remove-OldHistory {
421
- <# .SYNOPSIS --prune-history N: delete logs, reports and bundles older than N days. Honours --dry-run. #>
422
- param([int] $Days = 90)
423
- $ws = $Script:WS
424
- Write-Box "Prune run history older than $Days days" $ws.Home
425
- $ws.AllowOwnData = $true
426
- $n = 0; $bytes = [long]0
427
- foreach ($dir in @($ws.LogsDir, $ws.ReportsDir, $ws.FeedbackDir)) {
428
- if (-not (Test-Path -LiteralPath $dir)) { continue }
429
- foreach ($f in (Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue)) {
430
- if ($f.FullName -eq $ws.LogFile) { continue }
431
- if ((Get-IdleDays $f.FullName) -lt $Days) { continue }
432
- $r = Remove-PathSafe -Path $f.FullName -Within $dir
433
- if ($r.Removed) { $n++; $bytes += $r.Bytes }
434
- }
435
- }
436
- $ws.AllowOwnData = $false
437
- if ($ws.DryRun) { Write-Info ("would remove $n file(s), " + (Format-Bytes $bytes)) } else { Write-Ok ("removed $n file(s), " + (Format-Bytes $bytes)) }
438
- }
1
+ # release_helpers.ps1 - version, --list, --self-test, feedback, debug bundle, scheduled task, profile alias, data removal.
2
+
3
+ function Show-Version {
4
+ Write-Host ''
5
+ Write-Host (" $Script:WS_NAME v$(Get-ToolVersion)")
6
+ Write-Host (" $Script:WS_TAGLINE")
7
+ Write-Host ''
8
+ Write-Host (" Author: $Script:WS_AUTHOR")
9
+ Write-Host (" Email: $Script:WS_EMAIL")
10
+ Write-Host (" Web: $Script:WS_WEB")
11
+ Write-Host (" LinkedIn: $Script:WS_LINKEDIN")
12
+ Write-Host (" Source: $Script:WS_REPO")
13
+ Write-Host (" License: $Script:WS_LICENSE")
14
+ Write-Host ''
15
+ Write-Host (" PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))")
16
+ Write-Host (" Project root: $Script:WS_ROOT")
17
+ Write-Host ''
18
+ }
19
+
20
+ function Show-SectionList {
21
+ # --list --json prints the catalogue and nothing else: the human table below uses Write-Host, which would
22
+ # break the one-line-on-stdout promise.
23
+ if ($Script:WS.JsonMode) { [Console]::Out.WriteLine(((Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress)); return }
24
+ Write-Host ''
25
+ Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f '#', 'SECTION', 'TIER', 'ADMIN', 'BATCH')
26
+ foreach ($s in $Script:WS_SECTIONS) {
27
+ $adm = '-'
28
+ if ($s.Admin) { $adm = 'yes' }
29
+ Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f $s.Id, $s.Title, $s.Tier, $adm, $s.Batch)
30
+ }
31
+ Write-Host ''
32
+ Write-Host (" safe batch (--all): " + ($Script:WS_SAFE_BATCH -join ',') + " (+" + ($Script:WS_SAFE_BATCH_ADMIN -join ',') + " when elevated)")
33
+ foreach ($k in ($Script:WS_PROFILES.Keys | Sort-Object)) { Write-Host (" profile {0,-11} {1}" -f $k, ($Script:WS_PROFILES[$k] -join ',')) }
34
+ Write-Host ' batch policy: safe = runs in --all | optin = --only/--profile with --yes | deep = also needs --i-understand-deep | interactive = never batch'
35
+ Write-Host ''
36
+ }
37
+
38
+ # ---------------------------------------------------------------------------------------------
39
+ # Self-test
40
+ # ---------------------------------------------------------------------------------------------
41
+
42
+ function Invoke-SelfTest {
43
+ <# .SYNOPSIS Verify syntax, ASCII-only source, safety guards, dry-run, junctions and long paths. Exit 1 on any failure. #>
44
+ $ws = $Script:WS
45
+ $fails = 0
46
+ $checks = 0
47
+ Write-Box 'Self-test' 'Verify syntax, safety guards, dry-run guarantee, junction handling'
48
+
49
+ Write-Section '[1] PowerShell'
50
+ $checks++
51
+ if ($PSVersionTable.PSVersion.Major -ge 5) { Write-Ok "PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))" } else { Write-Err "PowerShell $($PSVersionTable.PSVersion) - 5.1+ required"; $fails++ }
52
+
53
+ Write-Section '[2] Optional tools'
54
+ foreach ($c in @('robocopy.exe', 'cleanmgr.exe', 'Dism.exe', 'powercfg.exe', 'wevtutil.exe', 'diskpart.exe', 'wsl.exe', 'docker', 'npm', 'yarn', 'pnpm', 'pip', 'uv', 'composer', 'dotnet', 'cargo', 'go')) {
55
+ if (Test-CommandPresent $c) { Write-Ok $c } else { Write-Note "$c (optional - the steps that use it are skipped)" }
56
+ }
57
+
58
+ Write-Section '[3] Script syntax'
59
+ $files = @(Get-Item -LiteralPath $ws.ScriptPath) + @(Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'lib'), (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)
60
+ foreach ($f in $files) {
61
+ $checks++
62
+ $tokens = $null; $errors = $null
63
+ $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors)
64
+ if ($errors.Count -eq 0) { Write-Ok $f.Name } else { Write-Err "$($f.Name): $($errors[0].Message)"; $fails++ }
65
+ }
66
+
67
+ Write-Section '[4] ASCII-only source'
68
+ $srcFiles = @($files) + @(Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'bin') -Filter '*.js' -File -ErrorAction SilentlyContinue)
69
+ foreach ($f in $srcFiles) {
70
+ $checks++
71
+ $bad = 0
72
+ foreach ($b in [IO.File]::ReadAllBytes($f.FullName)) { if ($b -gt 127) { $bad++; break } }
73
+ if ($bad -eq 0) { Write-Ok $f.Name } else { Write-Err "$($f.Name): $bad non-ASCII byte(s) - PowerShell 5.1 would misread this file"; $fails++ }
74
+ }
75
+
76
+ Write-Section '[5] Safety guards'
77
+ $U = $env:USERPROFILE; $L = $env:LOCALAPPDATA
78
+ $mustRefuse = @("$env:SystemDrive\", $env:SystemRoot, "$env:SystemRoot\System32", $env:ProgramFiles, $U, "$U\Documents", "$U\Pictures\holiday", "$U\.ssh\id_ed25519", "$U\.claude\settings.json", "$env:APPDATA\npm", "$env:SystemRoot\Prefetch", "$env:SystemRoot\Installer", "$L\Temp\..\..\Roaming", '\\server\share\x', "$L\Google\Chrome\User Data\Default\Local Storage\leveldb", "$L\Google\Chrome\User Data\Default", "$L\Google\Chrome\User Data\Profile 12", "$L\Packages\Some.App_abc\LocalState\data", "$env:APPDATA\Code\User\globalStorage\state.vscdb", "$U\.cache\claude\x", "$env:SystemDrive\hiberfil.sys", $ws.Home)
79
+ foreach ($p in $mustRefuse) {
80
+ $checks++
81
+ $why = Get-ProtectionReason $p
82
+ if ($why) { Write-Ok "refuses $p ($why)" } else { Write-Err "DANGEROUS: not protected: $p"; $fails++ }
83
+ }
84
+ $mustAllow = @("$L\npm-cache\_cacache", "$L\Temp\some-tmp-file.tmp", "$L\Google\Chrome\User Data\Default\Cache\Cache_Data", "$L\Yarn\Cache\v6", "$U\.gradle\caches\modules-2", "$L\Android\Sdk\.temp\x", "$env:APPDATA\Code\CachedExtensionVSIXs", "$U\.vscode\extensions\some.ext-1.0.0")
85
+ foreach ($p in $mustAllow) {
86
+ $checks++
87
+ $why = Get-ProtectionReason $p
88
+ if (-not $why) { Write-Ok "allows $p" } else { Write-Err "would block a legitimate target: $p ($why)"; $fails++ }
89
+ }
90
+ $checks++
91
+ if ((Test-PathWithin -Path "$L\Temp\a\b" -Within "$L\Temp") -and -not (Test-PathWithin -Path "$L\Tempx\a" -Within "$L\Temp") -and (Test-PathWithin -Path "$L\Temp" -Within "$L\Temp")) { Write-Ok 'Test-PathWithin: inside / sibling-prefix / equal' } else { Write-Err 'Test-PathWithin is wrong'; $fails++ }
92
+
93
+ Write-Section '[6] Declared targets are never protected'
94
+ $targets = @(Get-AllTargets | Where-Object { $_.Kind -in 'dir', 'file' })
95
+ $checks++
96
+ $leaked = 0
97
+ foreach ($t in $targets) {
98
+ $why = Get-ProtectionReason $t.Path
99
+ if ($why) { Write-Err "target [$($t.Section)] $($t.Label) is inside a protected path: $($t.Path) ($why)"; $leaked++ }
100
+ }
101
+ if ($leaked -eq 0) { Write-Ok "$($targets.Count) declared targets, none inside a protected path" } else { $fails += $leaked }
102
+
103
+ Write-Section '[7] Fixture: junctions, dry-run, stale prune, keep-newest, long paths'
104
+ $fx = Join-Path $ws.Home ('selftest-' + [guid]::NewGuid().ToString('N'))
105
+ $savedDry = $ws.DryRun; $savedYes = $ws.Yes; $savedQuiet = $ws.Quiet
106
+ # Helper output (including the refusals we deliberately provoke) is muted; only the verdict lines print.
107
+ $mute = { $Script:WS.Mute = $true }
108
+ $unmute = { $Script:WS.Mute = $false }
109
+ try {
110
+ New-Item -ItemType Directory -Force -Path $fx | Out-Null
111
+ $ws.AllowOwnData = $true
112
+ & $mute
113
+ $target = Join-Path $fx 'target'; New-Item -ItemType Directory -Force -Path $target | Out-Null
114
+ $sentinel = Join-Path $target 'sentinel.txt'; Set-Content -LiteralPath $sentinel -Value 'keep me'
115
+ $cache = Join-Path $fx 'cache'; New-Item -ItemType Directory -Force -Path $cache | Out-Null
116
+ New-Item -ItemType Junction -Path (Join-Path $cache 'link') -Target $target | Out-Null
117
+ Set-Content -LiteralPath (Join-Path $cache 'junk.txt') -Value 'junk'
118
+ # 7a: removing a junction removes the link only
119
+ $checks++
120
+ $ws.DryRun = $false
121
+ $r = Remove-PathSafe -Path (Join-Path $cache 'link') -Within $cache
122
+ & $unmute
123
+ if ($r.Removed -and (Test-Path -LiteralPath $sentinel) -and -not (Test-Path -LiteralPath (Join-Path $cache 'link'))) { Write-Ok 'junction removed as a link; target sentinel survives' } else { Write-Err 'junction handling is wrong'; $fails++ }
124
+ & $mute
125
+ # 7b: a junction nested inside a tree being removed is not followed
126
+ $checks++
127
+ $tree = Join-Path $fx 'tree'; New-Item -ItemType Directory -Force -Path (Join-Path $tree 'sub') | Out-Null
128
+ New-Item -ItemType Junction -Path (Join-Path $tree 'sub\jl') -Target $target | Out-Null
129
+ Set-Content -LiteralPath (Join-Path $tree 'sub\file.txt') -Value 'x'
130
+ $r = Remove-PathSafe -Path $tree -Within $fx
131
+ & $unmute
132
+ if ($r.Removed -and (Test-Path -LiteralPath $sentinel) -and -not (Test-Path -LiteralPath $tree)) { Write-Ok 'nested junction not followed; tree removed; sentinel survives' } else { Write-Err 'nested junction handling is wrong'; $fails++ }
133
+ & $mute
134
+ # 7c: dry-run changes nothing
135
+ $checks++
136
+ $old = Join-Path $fx 'old'; New-Item -ItemType Directory -Force -Path (Join-Path $old 'deep') | Out-Null
137
+ $oldFile = Join-Path $old 'deep\old.bin'; [IO.File]::WriteAllBytes($oldFile, (New-Object byte[] 4096))
138
+ $freshFile = Join-Path $old 'fresh.bin'; [IO.File]::WriteAllBytes($freshFile, (New-Object byte[] 2048))
139
+ $ancient = (Get-Date).AddDays(-400)
140
+ foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $oldFile -Name $ts -Value $ancient }
141
+ # Files carry the evidence (name, size, write time); directory timestamps are excluded because NTFS
142
+ # updates a folder's own LastWriteTime lazily, which made an earlier version of this check flap.
143
+ $snapshot = { param($root) ((Get-ChildItem -LiteralPath $root -Recurse -Force -File | ForEach-Object { "$($_.FullName)|$($_.Length)|$($_.LastWriteTimeUtc.Ticks)" }) + (Get-ChildItem -LiteralPath $root -Recurse -Force -Directory | ForEach-Object { "D|$($_.FullName)" })) -join "`n" }
144
+ $snapBefore = & $snapshot $old
145
+ $ws.DryRun = $true
146
+ $estBefore = $ws.TotalEstimated
147
+ $r = Remove-StaleFiles -Root $old -Within $fx -Days 100 -Label 'fixture'
148
+ $snapAfter = & $snapshot $old
149
+ $ws.DryRun = $false
150
+ & $unmute
151
+ if ($snapBefore -eq $snapAfter -and $r.Freed -eq 4096 -and ($ws.TotalEstimated - $estBefore) -eq 4096) { Write-Ok 'dry-run changed nothing and estimated exactly the stale 4096 bytes' } else { Write-Err "dry-run guarantee broken (freed=$($r.Freed), tree changed=$($snapBefore -ne $snapAfter))"; $fails++ }
152
+ & $mute
153
+ # 7d: real stale prune removes only the old file and sweeps the empty dir
154
+ $checks++
155
+ $r = Remove-StaleFiles -Root $old -Within $fx -Days 100 -Label 'fixture'
156
+ & $unmute
157
+ if ((-not (Test-Path -LiteralPath $oldFile)) -and (Test-Path -LiteralPath $freshFile) -and (-not (Test-Path -LiteralPath (Join-Path $old 'deep'))) -and $r.Freed -eq 4096) { Write-Ok 'stale prune removed the idle file, kept the fresh one, swept the empty directory' } else { Write-Err 'stale prune behaved wrongly'; $fails++ }
158
+ & $mute
159
+ # 7e: keep-newest keeps the freshest unit even when every unit is idle
160
+ $checks++
161
+ $units = Join-Path $fx 'units'
162
+ foreach ($v in '1.0.0', '2.0.0') { New-Item -ItemType Directory -Force -Path (Join-Path $units $v) | Out-Null; Set-Content -LiteralPath (Join-Path $units "$v\bin.txt") -Value $v }
163
+ $t1 = (Get-Date).AddDays(-300); $t2 = (Get-Date).AddDays(-200)
164
+ foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath (Join-Path $units '1.0.0\bin.txt') -Name $ts -Value $t1; Set-ItemProperty -LiteralPath (Join-Path $units '2.0.0\bin.txt') -Name $ts -Value $t2 }
165
+ $r = Remove-StaleUnits -Root $units -Within $fx -Days 100 -KeepNewest -DirectoriesOnly -Label 'fixture units'
166
+ & $unmute
167
+ if ((Test-Path -LiteralPath (Join-Path $units '2.0.0')) -and -not (Test-Path -LiteralPath (Join-Path $units '1.0.0')) -and $r.Pruned -eq 1 -and $r.Kept -eq 1) { Write-Ok 'keep-newest kept 2.0.0 (200d idle) and pruned 1.0.0 (300d idle)' } else { Write-Err 'keep-newest behaved wrongly'; $fails++ }
168
+ & $mute
169
+ # 7f: long path
170
+ $checks++
171
+ $deep = Join-Path $fx 'long'; $p = $deep
172
+ for ($i = 0; $i -lt 12; $i++) { $p = "$p\segment_$('x' * 20)_$i" }
173
+ [IO.Directory]::CreateDirectory((Get-LongPath $p)) | Out-Null
174
+ [IO.File]::WriteAllText((Get-LongPath (Join-Path $p 'deep.txt')), 'x')
175
+ $r = Remove-PathSafe -Path $deep -Within $fx
176
+ & $unmute
177
+ if ($r.Removed -and -not (Test-DirPresent $deep) -and $p.Length -gt 260) { Write-Ok "long path ($($p.Length) chars) removed" } else { Write-Err 'long-path removal failed'; $fails++ }
178
+ & $mute
179
+ # 7g: refusals inside the fixture (the refusal messages themselves are muted; the verdicts print)
180
+ $checks++
181
+ $r = Remove-PathSafe -Path (Join-Path $fx 'target') -Within (Join-Path $fx 'cache')
182
+ & $unmute
183
+ if (-not $r.Removed -and $r.Reason -like 'outside declared root*' -and (Test-Path -LiteralPath $sentinel)) { Write-Ok 'a path outside its declared root is refused' } else { Write-Err 'Within guard failed'; $fails++ }
184
+ & $mute
185
+ $checks++
186
+ $r = Remove-PathSafe -Path $fx -Within $fx
187
+ & $unmute
188
+ if (-not $r.Removed -and (Test-Path -LiteralPath $fx)) { Write-Ok 'a root never deletes itself' } else { Write-Err 'root self-delete guard failed'; $fails++ }
189
+ $checks++
190
+ & $mute
191
+ $r = Remove-PathSafe -Path (Join-Path $env:USERPROFILE 'Documents') -Within $env:USERPROFILE
192
+ & $unmute
193
+ if (-not $r.Removed -and $r.Reason -like 'inside protected*') { Write-Ok 'the chokepoint refuses Documents even when a caller declares the profile as its root' } else { Write-Err 'protected-path refusal failed at the chokepoint'; $fails++ }
194
+ # 7h: editor extension leftovers - the manifest is an ARRAY (the ConvertFrom-Json shape that once collapsed 16 entries into 1)
195
+ $checks++
196
+ $extRoot = Join-Path $fx 'extensions'
197
+ foreach ($d in 'pub.alpha-1.0.0', 'pub.alpha-0.9.0', 'pub.beta-2.0.0-win32-x64', 'pub.gamma-1.0.0', 'pub.delta-3.0.0') { New-Item -ItemType Directory -Force -Path (Join-Path $extRoot $d) | Out-Null; Set-Content -LiteralPath (Join-Path $extRoot "$d\package.json") -Value '{}' }
198
+ Set-Content -LiteralPath (Join-Path $extRoot 'extensions.json') -Value '[{"identifier":{"id":"pub.alpha"},"version":"1.0.0","relativeLocation":"pub.alpha-1.0.0"},{"identifier":{"id":"pub.beta"},"version":"2.0.0","relativeLocation":"pub.beta-2.0.0-win32-x64"},{"identifier":{"id":"pub.delta"},"version":"3.0.0","relativeLocation":"pub.delta-3.0.0"}]'
199
+ Set-Content -LiteralPath (Join-Path $extRoot '.obsolete') -Value '{"pub.gamma-1.0.0":true}'
200
+ $folders = @()
201
+ foreach ($e in @(Get-ChildEntries $extRoot)) { if (($e -is [IO.DirectoryInfo]) -and $e.Name -match '^(.+?)-(\d+\.\d+\.\d+)([^\\]*)$') { $folders += [pscustomobject]@{ Name = $e.Name; Id = $Matches[1]; Path = (Remove-LongPrefix $e.FullName); Version = [version]$Matches[2] } } }
202
+ $plan = Get-SupersededExtensionPlan -Root $extRoot -Folders $folders
203
+ $names = @($plan.Victims | ForEach-Object { $_.Name } | Sort-Object)
204
+ if (($names -join ',') -eq 'pub.alpha-0.9.0,pub.gamma-1.0.0') { Write-Ok 'extension leftovers: superseded alpha-0.9.0 + obsolete gamma go; the 3 installed folders stay' } else { Write-Err "extension leftover plan wrong: [$($names -join ',')]"; $fails++ }
205
+ } catch {
206
+ & $unmute
207
+ Write-Err "fixture test crashed: $($_.Exception.Message)"; $fails++
208
+ } finally {
209
+ & $unmute
210
+ $ws.DryRun = $savedDry; $ws.Yes = $savedYes; $ws.Quiet = $savedQuiet
211
+ try { if (Test-Path -LiteralPath $fx) { [IO.Directory]::Delete((Get-LongPath $fx), $true) } } catch { $null = $_ }
212
+ $ws.AllowOwnData = $false
213
+ }
214
+
215
+ Write-Section '[8] Pure helpers'
216
+ $checks++
217
+ $sel = ConvertTo-IndexList -Text '1,3,5-7,9-8,99' -Total 10
218
+ if (($sel -join ',') -eq '1,3,5,6,7,8,9') { Write-Ok "multi-select parser: '1,3,5-7,9-8,99' -> $($sel -join ',')" } else { Write-Err "multi-select parser wrong: $($sel -join ',')"; $fails++ }
219
+ $checks++
220
+ if ((Format-Bytes 0) -eq '0 B' -and (Format-Bytes 1536) -eq '1.5 KB' -and (Format-Bytes 1073741824) -eq '1.0 GB') { Write-Ok 'Format-Bytes' } else { Write-Err "Format-Bytes wrong: $(Format-Bytes 1536)"; $fails++ }
221
+
222
+ Write-Section '[9] No network code'
223
+ $checks++
224
+ $needles = @(('Invoke-Web' + 'Request'), ('Invoke-Rest' + 'Method'), ('Net.Web' + 'Client'), ('Http' + 'Client'), ('Sockets.' + 'TcpClient'), ('curl' + '.exe'), ('wget' + ' '))
225
+ $hits = 0
226
+ foreach ($f in $srcFiles) {
227
+ $text = [IO.File]::ReadAllText($f.FullName)
228
+ foreach ($n in $needles) { if ($text.IndexOf($n, [StringComparison]::OrdinalIgnoreCase) -ge 0) { Write-Err "network call in $($f.Name): $n"; $hits++ } }
229
+ }
230
+ if ($hits -eq 0) { Write-Ok 'no HTTP or socket calls in the source' } else { $fails += $hits }
231
+
232
+ Write-Section '[10] Version parity'
233
+ $checks++
234
+ $verFile = Join-Path $Script:WS_ROOT 'VERSION'
235
+ $pkgFile = Join-Path $Script:WS_ROOT 'package.json'
236
+ $vFile = ''; $vPkg = ''
237
+ if (Test-Path -LiteralPath $verFile) { $vFile = (Get-Content -LiteralPath $verFile -Raw).Trim() }
238
+ if (Test-Path -LiteralPath $pkgFile) { try { $vPkg = (Get-Content -LiteralPath $pkgFile -Raw | ConvertFrom-Json).version } catch { $vPkg = '?' } }
239
+ if ($vFile -eq $Script:WS_VERSION_FALLBACK -and $vPkg -eq $Script:WS_VERSION_FALLBACK) { Write-Ok "VERSION, package.json and lib/constants.ps1 all say $vPkg" } else { Write-Err "version mismatch: VERSION='$vFile' package.json='$vPkg' constants='$Script:WS_VERSION_FALLBACK'"; $fails++ }
240
+
241
+ Write-Section '[11] Output paths'
242
+ foreach ($d in @($ws.LogsDir, $ws.ReportsDir)) {
243
+ $checks++
244
+ try { $probe = Join-Path $d ('.write-test-' + [guid]::NewGuid().ToString('N')); Set-Content -LiteralPath $probe -Value 'x'; Remove-Item -LiteralPath $probe -Force; Write-Ok "$d writable" } catch { Write-Err "$d not writable"; $fails++ }
245
+ }
246
+
247
+ if (Get-Command Invoke-SelfTestExtra -ErrorAction SilentlyContinue) {
248
+ $extra = Invoke-SelfTestExtra
249
+ $checks += [int]$extra.Checks
250
+ $fails += [int]$extra.Fails
251
+ }
252
+
253
+ Write-Separator
254
+ if ($fails -eq 0) { Write-Ok "all $checks checks passed - $Script:WS_NAME is ready"; return $Script:WS_EXIT_OK }
255
+ Write-Err "$fails of $checks checks FAILED"
256
+ return $Script:WS_EXIT_FAIL
257
+ }
258
+
259
+ # ---------------------------------------------------------------------------------------------
260
+ # Feedback, bug reports, debug bundle
261
+ # ---------------------------------------------------------------------------------------------
262
+
263
+ function Show-Feedback {
264
+ Write-Box 'Send feedback / report a bug' 'All offline - nothing leaves your machine unless you send it'
265
+ Write-Plain " Issues: $Script:WS_ISSUES"
266
+ Write-Plain " Email: $Script:WS_EMAIL"
267
+ Write-Plain " Web: $Script:WS_WEB"
268
+ Write-UiLine '' 'Gray'
269
+ Write-Plain ' Please include: Windows version, PowerShell version ($PSVersionTable.PSVersion),'
270
+ Write-Plain " $Script:WS_NAME version (currently v$(Get-ToolVersion)), the exact command, what you expected,"
271
+ Write-Plain ' what happened, and the log from --debug-bundle (review it first: it holds paths from your machine).'
272
+ Write-UiLine '' 'Gray'
273
+ Write-Plain ' windowsweep --report-issue opens a pre-filled GitHub issue in your browser'
274
+ Write-Plain ' windowsweep --debug-bundle zips the latest log + report for attaching'
275
+ Write-UiLine '' 'Gray'
276
+ Write-Note 'Privacy: windowsweep makes no network calls. Logs and reports are written only under ~\.windowsweep.'
277
+ }
278
+
279
+ function Invoke-ReportIssue {
280
+ <# .SYNOPSIS Build a GitHub new-issue URL with environment facts and open it in the default browser after confirming. #>
281
+ $ws = $Script:WS
282
+ $title = "[bug] $Script:WS_NAME v$(Get-ToolVersion): "
283
+ $body = @(
284
+ '**What happened**', '', '', '**What you expected**', '', '', '**Command you ran**', '', '```', 'windowsweep ', '```', '',
285
+ '**Environment (auto-filled, review before submitting)**',
286
+ "- OS: $($ws.OsCaption) ($([Environment]::OSVersion.Version))",
287
+ "- PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))",
288
+ "- $Script:WS_NAME`: v$(Get-ToolVersion) via $($ws.Launcher)",
289
+ "- Elevated: $($ws.IsAdmin)", '',
290
+ '**Log excerpt** (from `windowsweep --debug-bundle`, paths redacted as you see fit)', '', '```', '', '```'
291
+ ) -join "`n"
292
+ $url = "$Script:WS_ISSUES/new?title=$([Uri]::EscapeDataString($title))&labels=bug&body=$([Uri]::EscapeDataString($body))"
293
+ Write-Box 'Report an issue' 'Opens a pre-filled GitHub issue; nothing is sent until you submit it'
294
+ Write-Note 'Environment facts included: OS build, PowerShell version, tool version, launcher, elevation. No paths, no logs.'
295
+ if (Confirm-Ui -Prompt 'Open the pre-filled issue in your browser now?' -Default 'y') {
296
+ try { Start-Process $url | Out-Null; Write-Ok 'opened - review, edit, then submit' } catch { Write-Warn "could not open a browser. URL:"; Write-Plain $url }
297
+ } else {
298
+ Write-Plain ' URL (copy it into a browser):'
299
+ Write-Plain " $url"
300
+ }
301
+ }
302
+
303
+ function New-DebugBundle {
304
+ Write-Box 'Debug bundle' 'Latest log + latest report + system manifest, zipped locally'
305
+ $zip = New-BundleZip -Prefix 'debug-bundle' -Manifest (New-SystemManifest -Kind 'debug bundle')
306
+ if ($zip) {
307
+ Write-Ok "bundle created: $zip"
308
+ Write-Warn 'Review the bundle before sharing - it contains paths from your machine and a cache-size snapshot.'
309
+ Write-Note "Attach it to an issue at $Script:WS_ISSUES"
310
+ }
311
+ }
312
+
313
+ # ---------------------------------------------------------------------------------------------
314
+ # Scheduled task, profile alias, data removal, history pruning
315
+ # ---------------------------------------------------------------------------------------------
316
+
317
+ $Script:WS_TASK_NAME = 'windowsweep weekly safe cleanup'
318
+
319
+ function Get-LaunchCommand {
320
+ <# .SYNOPSIS The command the scheduled task / alias should run: the launcher that started us, or the script. #>
321
+ $ws = $Script:WS
322
+ if ($ws.Launcher -eq 'node' -and -not $ws.Npx) {
323
+ $cmd = Get-Command windowsweep -ErrorAction SilentlyContinue
324
+ if ($cmd) { return [pscustomobject]@{ Exe = $cmd.Source; Args = @() } }
325
+ }
326
+ return [pscustomobject]@{ Exe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; Args = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', "`"$($ws.ScriptPath)`"") }
327
+ }
328
+
329
+ function Test-NpxInstallerRefusal {
330
+ <# .SYNOPSIS Under npx the package lives in a cache npm evicts; a task or alias pointing there would break later. #>
331
+ param([string] $Flag)
332
+ if (-not $Script:WS.Npx) { return $false }
333
+ Write-Err "$Flag is refused under npx: it would point at the npx cache, which npm evicts."
334
+ Write-Plain ' Install once, then register from the global command:'
335
+ Write-Plain ' npm install -g windowsweep'
336
+ Write-Plain " windowsweep $Flag"
337
+ $Script:WS.ExitCode = $Script:WS_EXIT_REFUSED
338
+ return $true
339
+ }
340
+
341
+ function Install-WeeklyTask {
342
+ Write-Box 'Install weekly Scheduled Task' "$Script:WS_TASK_NAME - Sundays 03:00, safe batch, no prompts"
343
+ if (Test-NpxInstallerRefusal '--install-task') { return }
344
+ if (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue) { Write-Info 'task already exists'; return }
345
+ $lc = Get-LaunchCommand
346
+ # --notify is part of the task action: an unattended Sunday run that says nothing is a run nobody sees.
347
+ $argLine = (@($lc.Args) + @('--all', '--yes', '--quiet', '--no-color', '--notify')) -join ' '
348
+ Write-Info "action: $($lc.Exe) $argLine"
349
+ if ($Script:WS.DryRun) { Write-DryRun 'would register the task'; return }
350
+ if (-not (Confirm-Ui -Prompt 'Register this task for your user account?' -Default 'y')) { Write-Info 'skipped'; return }
351
+ try {
352
+ $action = New-ScheduledTaskAction -Execute $lc.Exe -Argument $argLine
353
+ $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 3am
354
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfIdle:$false -ExecutionTimeLimit (New-TimeSpan -Hours 3)
355
+ Register-ScheduledTask -TaskName $Script:WS_TASK_NAME -Action $action -Trigger $trigger -Settings $settings -Description "$Script:WS_NAME safe batch (--all --yes). $Script:WS_REPO" -ErrorAction Stop | Out-Null
356
+ Write-Ok "registered '$Script:WS_TASK_NAME' (runs as your user, Sundays 03:00; catches up if the PC was off)"
357
+ } catch { Write-Err "could not register the task: $($_.Exception.Message)" }
358
+ }
359
+
360
+ function Uninstall-WeeklyTask {
361
+ Write-Box 'Remove weekly Scheduled Task'
362
+ if (-not (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue)) { Write-Info 'no task found'; return }
363
+ if ($Script:WS.DryRun) { Write-DryRun 'would unregister the task'; return }
364
+ if (Confirm-Ui -Prompt "Remove '$Script:WS_TASK_NAME'?" -Default 'y') {
365
+ try { Unregister-ScheduledTask -TaskName $Script:WS_TASK_NAME -Confirm:$false -ErrorAction Stop; Write-Ok 'task removed' } catch { Write-Err "could not remove the task: $($_.Exception.Message)" }
366
+ }
367
+ }
368
+
369
+ $Script:WS_ALIAS_MARK = '# windowsweep alias'
370
+
371
+ function Install-ProfileAlias {
372
+ Write-Box 'Install profile alias' "Adds a 'cleanup' function to your PowerShell profile"
373
+ if (Test-NpxInstallerRefusal '--install-alias') { return }
374
+ $profilePath = $PROFILE.CurrentUserAllHosts
375
+ $lc = Get-LaunchCommand
376
+ $line = "function cleanup { & `"$($lc.Exe)`" $($lc.Args -join ' ') @args }"
377
+ if ((Test-Path -LiteralPath $profilePath) -and (Select-String -LiteralPath $profilePath -Pattern ([regex]::Escape($Script:WS_ALIAS_MARK)) -Quiet)) { Write-Info "alias already present in $profilePath"; return }
378
+ Write-Info "will append to $profilePath"
379
+ Write-Note $line
380
+ if ($Script:WS.DryRun) { Write-DryRun 'would append the alias'; return }
381
+ if (-not (Confirm-Ui -Prompt 'Add it?' -Default 'y')) { Write-Info 'skipped'; return }
382
+ try {
383
+ $dir = Split-Path -Parent $profilePath
384
+ if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
385
+ Add-Content -LiteralPath $profilePath -Value @('', $Script:WS_ALIAS_MARK, $line)
386
+ Write-Ok "added. Open a new PowerShell window (or run '. `$PROFILE.CurrentUserAllHosts'), then type: cleanup"
387
+ } catch { Write-Err "could not write the profile: $($_.Exception.Message)" }
388
+ }
389
+
390
+ function Uninstall-ProfileAlias {
391
+ Write-Box 'Remove profile alias'
392
+ $profilePath = $PROFILE.CurrentUserAllHosts
393
+ if (-not (Test-Path -LiteralPath $profilePath)) { Write-Info 'no profile file'; return }
394
+ $lines = Get-Content -LiteralPath $profilePath
395
+ if (-not ($lines | Where-Object { $_ -eq $Script:WS_ALIAS_MARK })) { Write-Info 'alias not present'; return }
396
+ if ($Script:WS.DryRun) { Write-DryRun 'would remove the alias block'; return }
397
+ if (-not (Confirm-Ui -Prompt "Remove the windowsweep alias from $profilePath ?" -Default 'y')) { Write-Info 'skipped'; return }
398
+ $out = @(); $skipNext = $false
399
+ foreach ($l in $lines) {
400
+ if ($l -eq $Script:WS_ALIAS_MARK) { $skipNext = $true; continue }
401
+ if ($skipNext) { $skipNext = $false; if ($l -like 'function cleanup*') { continue } }
402
+ $out += $l
403
+ }
404
+ Set-Content -LiteralPath $profilePath -Value $out
405
+ Write-Ok 'alias removed'
406
+ }
407
+
408
+ function Remove-ToolData {
409
+ $ws = $Script:WS
410
+ Write-Box 'Remove windowsweep data' $ws.Home
411
+ if (-not (Test-Path -LiteralPath $ws.Home)) { Write-Info 'nothing to remove'; return }
412
+ $bytes = Get-DirectoryBytes $ws.Home
413
+ Write-Info ("$($ws.Home) holds " + (Format-Bytes $bytes) + ' of logs, reports, bundles and config')
414
+ if ($ws.DryRun) { Write-DryRun "would remove $($ws.Home)"; return }
415
+ if (-not (Confirm-Ui -Prompt 'Delete it all (logs, reports, your developer answer)?' -Default 'n' -NoAutoYes)) { Write-Info 'skipped'; return }
416
+ $ws.LogFile = $null
417
+ try { [IO.Directory]::Delete((Get-LongPath $ws.Home), $true); Write-Ok 'removed' } catch { Write-Err "could not remove: $($_.Exception.Message)" }
418
+ }
419
+
420
+ function Remove-OldHistory {
421
+ <# .SYNOPSIS --prune-history N: delete logs, reports and bundles older than N days. Honours --dry-run. #>
422
+ param([int] $Days = 90)
423
+ $ws = $Script:WS
424
+ Write-Box "Prune run history older than $Days days" $ws.Home
425
+ $ws.AllowOwnData = $true
426
+ $n = 0; $bytes = [long]0
427
+ foreach ($dir in @($ws.LogsDir, $ws.ReportsDir, $ws.FeedbackDir)) {
428
+ if (-not (Test-Path -LiteralPath $dir)) { continue }
429
+ foreach ($f in (Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue)) {
430
+ if ($f.FullName -eq $ws.LogFile) { continue }
431
+ if ((Get-IdleDays $f.FullName) -lt $Days) { continue }
432
+ $r = Remove-PathSafe -Path $f.FullName -Within $dir
433
+ if ($r.Removed) { $n++; $bytes += $r.Bytes }
434
+ }
435
+ }
436
+ $ws.AllowOwnData = $false
437
+ if ($ws.DryRun) { Write-Info ("would remove $n file(s), " + (Format-Bytes $bytes)) } else { Write-Ok ("removed $n file(s), " + (Format-Bytes $bytes)) }
438
+ }