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
@@ -1,160 +1,160 @@
1
- # projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
2
-
3
- $Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
4
- # Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
5
- # A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
6
- # .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
7
- $Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
8
- $Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
9
- $Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
10
-
11
- function Get-ProjectScanRoots {
12
- $ws = $Script:WS
13
- $U = $Script:P.U
14
- $roots = @()
15
- if ($ws.ScanRoots.Count -gt 0) {
16
- foreach ($r in $ws.ScanRoots) {
17
- $f = Get-FullPath $r
18
- if (-not $f -or -not (Test-DirPresent $f)) { Write-Warn "scan root not found: $r"; continue }
19
- if ($f -match '^[A-Za-z]:\\?$' -or $f.Equals($U, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "refusing to scan a whole drive or your profile root: $f (name a projects folder)"; continue }
20
- $roots += $f
21
- }
22
- return $roots
23
- }
24
- $names = @('source\repos', 'source', 'repos', 'Projects', 'projects', 'code', 'Code', 'dev', 'Dev', 'Development', 'work', 'Work', 'git', 'GitHub', 'Documents\GitHub', 'Documents\Projects', 'Documents\code', 'Documents\dev', 'Documents\repos')
25
- foreach ($n in $names) { $p = Join-Path $U $n; if (Test-DirPresent $p) { $roots += (Get-FullPath $p) } }
26
- foreach ($d in [IO.DriveInfo]::GetDrives()) {
27
- if (-not $d.IsReady -or $d.DriveType -ne [IO.DriveType]::Fixed) { continue }
28
- foreach ($n in @('work', 'code', 'projects', 'repos', 'dev', 'src', 'git', 'Projects', 'Code', 'Work')) {
29
- $p = Join-Path $d.Name $n
30
- if ((Test-DirPresent $p) -and ($roots -notcontains (Get-FullPath $p))) { $roots += (Get-FullPath $p) }
31
- }
32
- }
33
- return @($roots | Select-Object -Unique)
34
- }
35
-
36
- function Get-Targets17 {
37
- $roots = @(Get-ProjectScanRoots)
38
- $t = @()
39
- foreach ($r in $roots) { $t += (New-Target 17 'Project scan root' $r -Kind cmd -Note ($Script:WS_ARTEFACT_DIRS -join ', ')) }
40
- if ($t.Count -eq 0) { $t += (New-Target 17 'Project scan root' '(none detected - pass --scan-roots "D:\work;E:\code")' -Kind cmd) }
41
- return $t
42
- }
43
-
44
- function Test-MarkedArtefactDir {
45
- <# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
46
- param([string] $Dir, [string] $Name)
47
- if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
48
- $names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
49
- foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
50
- return $false
51
- }
52
-
53
- function Test-ProjectMarker {
54
- param([string] $Dir, [string] $ArtefactName)
55
- $entries = Get-ChildEntries $Dir
56
- $names = @($entries | ForEach-Object { $_.Name })
57
- if ($ArtefactName -in 'bin', 'obj') { return [bool]($names | Where-Object { $_ -like '*.csproj' -or $_ -like '*.sln' -or $_ -like '*.fsproj' }) }
58
- foreach ($m in $Script:WS_PROJECT_MARKERS) { if ($names -contains $m) { return $true } }
59
- return ($names -contains '.git')
60
- }
61
-
62
- function Get-ProjectActivityDays {
63
- <# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
64
- param([string] $ProjectDir)
65
- $newest = [datetime]::MinValue
66
- $stack = New-Object System.Collections.Generic.Stack[string]
67
- $stack.Push($ProjectDir)
68
- $seen = 0
69
- while ($stack.Count -gt 0 -and $seen -lt 200000) {
70
- $dir = $stack.Pop()
71
- foreach ($e in (Get-ChildEntries $dir)) {
72
- $seen++
73
- if (Test-ReparsePoint $e) { continue }
74
- if ($e -is [IO.DirectoryInfo]) {
75
- if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
76
- $stack.Push((Remove-LongPrefix $e.FullName)); continue
77
- }
78
- $t = Get-NewestTimestampUtc $e
79
- if ($t -gt $newest) { $newest = $t }
80
- }
81
- }
82
- if ($newest -eq [datetime]::MinValue) { return (Get-IdleDays $ProjectDir) }
83
- return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
84
- }
85
-
86
- function Find-StaleArtefacts {
87
- <# .SYNOPSIS Artefact folders (node_modules, dist, ...) whose project has been idle for N+ days. #>
88
- param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 6)
89
- $ws = $Script:WS
90
- $found = New-Object System.Collections.Generic.List[object]
91
- $projectAge = @{}
92
- foreach ($root in $Roots) {
93
- $stack = New-Object System.Collections.Generic.Stack[object]
94
- $stack.Push(@($root, 0))
95
- while ($stack.Count -gt 0) {
96
- $item = $stack.Pop()
97
- $dir = [string]$item[0]; $depth = [int]$item[1]
98
- foreach ($e in (Get-ChildEntries $dir)) {
99
- if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
100
- $full = Remove-LongPrefix $e.FullName
101
- $skip = $false
102
- foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
103
- if ($skip) { continue }
104
- if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
105
- if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
106
- if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
107
- $age = $projectAge[$dir]
108
- if ($age -ge $Days) { $found.Add([pscustomobject]@{ Path = $full; Project = $dir; Age = $age; Bytes = [long](Get-DirectoryBytes $full); Root = $root }) }
109
- continue
110
- }
111
- if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
112
- if ($depth -lt $MaxDepth) { $stack.Push(@($full, $depth + 1)) }
113
- }
114
- }
115
- }
116
- return @($found | Sort-Object Bytes -Descending)
117
- }
118
-
119
- function Invoke-Section17 {
120
- $ws = $Script:WS
121
- Write-SectionIntro @(
122
- 'node_modules, dist, .next, target, vendor and friends are rebuilt by the next install or build. Only projects',
123
- "whose SOURCE files nobody touched for $($ws.Days)+ days are listed, and nothing goes without your selection.",
124
- 'Toolchain folders (.gradle, .nuget, .cargo, AppData) and .git are never entered.'
125
- )
126
- $roots = @(Get-ProjectScanRoots)
127
- if ($roots.Count -eq 0) { Write-Warn 'no project roots found. Pass --scan-roots "D:\work;E:\code" (semicolon-separated) or save scanRoots in config.json.'; return }
128
- Write-Info ("scanning: " + ($roots -join ' '))
129
- $found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
130
- if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
131
- $reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
132
- # One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
133
- $offered = @($found | Select-Object -First 200)
134
- if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
135
- Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
136
- $i = 1; $total = [long]0
137
- foreach ($f in $offered) {
138
- Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
139
- $reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
140
- Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
141
- $total += $f.Bytes; $i++
142
- }
143
- Write-Info ("total: " + (Format-Bytes $total))
144
- if (-not $ws.NoReport) {
145
- $out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
146
- try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
147
- }
148
- $picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
149
- if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
150
- if (-not $ws.DryRun) {
151
- # Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
152
- # An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
153
- if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
154
- }
155
- foreach ($k in $picks) {
156
- $f = $offered[$k - 1]
157
- $r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
158
- if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
159
- }
160
- }
1
+ # projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
2
+
3
+ $Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
4
+ # Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
5
+ # A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
6
+ # .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
7
+ $Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
8
+ $Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
9
+ $Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
10
+
11
+ function Get-ProjectScanRoots {
12
+ $ws = $Script:WS
13
+ $U = $Script:P.U
14
+ $roots = @()
15
+ if ($ws.ScanRoots.Count -gt 0) {
16
+ foreach ($r in $ws.ScanRoots) {
17
+ $f = Get-FullPath $r
18
+ if (-not $f -or -not (Test-DirPresent $f)) { Write-Warn "scan root not found: $r"; continue }
19
+ if ($f -match '^[A-Za-z]:\\?$' -or $f.Equals($U, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "refusing to scan a whole drive or your profile root: $f (name a projects folder)"; continue }
20
+ $roots += $f
21
+ }
22
+ return $roots
23
+ }
24
+ $names = @('source\repos', 'source', 'repos', 'Projects', 'projects', 'code', 'Code', 'dev', 'Dev', 'Development', 'work', 'Work', 'git', 'GitHub', 'Documents\GitHub', 'Documents\Projects', 'Documents\code', 'Documents\dev', 'Documents\repos')
25
+ foreach ($n in $names) { $p = Join-Path $U $n; if (Test-DirPresent $p) { $roots += (Get-FullPath $p) } }
26
+ foreach ($d in [IO.DriveInfo]::GetDrives()) {
27
+ if (-not $d.IsReady -or $d.DriveType -ne [IO.DriveType]::Fixed) { continue }
28
+ foreach ($n in @('work', 'code', 'projects', 'repos', 'dev', 'src', 'git', 'Projects', 'Code', 'Work')) {
29
+ $p = Join-Path $d.Name $n
30
+ if ((Test-DirPresent $p) -and ($roots -notcontains (Get-FullPath $p))) { $roots += (Get-FullPath $p) }
31
+ }
32
+ }
33
+ return @($roots | Select-Object -Unique)
34
+ }
35
+
36
+ function Get-Targets17 {
37
+ $roots = @(Get-ProjectScanRoots)
38
+ $t = @()
39
+ foreach ($r in $roots) { $t += (New-Target 17 'Project scan root' $r -Kind cmd -Note ($Script:WS_ARTEFACT_DIRS -join ', ')) }
40
+ if ($t.Count -eq 0) { $t += (New-Target 17 'Project scan root' '(none detected - pass --scan-roots "D:\work;E:\code")' -Kind cmd) }
41
+ return $t
42
+ }
43
+
44
+ function Test-MarkedArtefactDir {
45
+ <# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
46
+ param([string] $Dir, [string] $Name)
47
+ if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
48
+ $names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
49
+ foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
50
+ return $false
51
+ }
52
+
53
+ function Test-ProjectMarker {
54
+ param([string] $Dir, [string] $ArtefactName)
55
+ $entries = Get-ChildEntries $Dir
56
+ $names = @($entries | ForEach-Object { $_.Name })
57
+ if ($ArtefactName -in 'bin', 'obj') { return [bool]($names | Where-Object { $_ -like '*.csproj' -or $_ -like '*.sln' -or $_ -like '*.fsproj' }) }
58
+ foreach ($m in $Script:WS_PROJECT_MARKERS) { if ($names -contains $m) { return $true } }
59
+ return ($names -contains '.git')
60
+ }
61
+
62
+ function Get-ProjectActivityDays {
63
+ <# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
64
+ param([string] $ProjectDir)
65
+ $newest = [datetime]::MinValue
66
+ $stack = New-Object System.Collections.Generic.Stack[string]
67
+ $stack.Push($ProjectDir)
68
+ $seen = 0
69
+ while ($stack.Count -gt 0 -and $seen -lt 200000) {
70
+ $dir = $stack.Pop()
71
+ foreach ($e in (Get-ChildEntries $dir)) {
72
+ $seen++
73
+ if (Test-ReparsePoint $e) { continue }
74
+ if ($e -is [IO.DirectoryInfo]) {
75
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
76
+ $stack.Push((Remove-LongPrefix $e.FullName)); continue
77
+ }
78
+ $t = Get-NewestTimestampUtc $e
79
+ if ($t -gt $newest) { $newest = $t }
80
+ }
81
+ }
82
+ if ($newest -eq [datetime]::MinValue) { return (Get-IdleDays $ProjectDir) }
83
+ return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
84
+ }
85
+
86
+ function Find-StaleArtefacts {
87
+ <# .SYNOPSIS Artefact folders (node_modules, dist, ...) whose project has been idle for N+ days. #>
88
+ param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 6)
89
+ $ws = $Script:WS
90
+ $found = New-Object System.Collections.Generic.List[object]
91
+ $projectAge = @{}
92
+ foreach ($root in $Roots) {
93
+ $stack = New-Object System.Collections.Generic.Stack[object]
94
+ $stack.Push(@($root, 0))
95
+ while ($stack.Count -gt 0) {
96
+ $item = $stack.Pop()
97
+ $dir = [string]$item[0]; $depth = [int]$item[1]
98
+ foreach ($e in (Get-ChildEntries $dir)) {
99
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
100
+ $full = Remove-LongPrefix $e.FullName
101
+ $skip = $false
102
+ foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
103
+ if ($skip) { continue }
104
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
105
+ if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
106
+ if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
107
+ $age = $projectAge[$dir]
108
+ if ($age -ge $Days) { $found.Add([pscustomobject]@{ Path = $full; Project = $dir; Age = $age; Bytes = [long](Get-DirectoryBytes $full); Root = $root }) }
109
+ continue
110
+ }
111
+ if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
112
+ if ($depth -lt $MaxDepth) { $stack.Push(@($full, $depth + 1)) }
113
+ }
114
+ }
115
+ }
116
+ return @($found | Sort-Object Bytes -Descending)
117
+ }
118
+
119
+ function Invoke-Section17 {
120
+ $ws = $Script:WS
121
+ Write-SectionIntro @(
122
+ 'node_modules, dist, .next, target, vendor and friends are rebuilt by the next install or build. Only projects',
123
+ "whose SOURCE files nobody touched for $($ws.Days)+ days are listed, and nothing goes without your selection.",
124
+ 'Toolchain folders (.gradle, .nuget, .cargo, AppData) and .git are never entered.'
125
+ )
126
+ $roots = @(Get-ProjectScanRoots)
127
+ if ($roots.Count -eq 0) { Write-Warn 'no project roots found. Pass --scan-roots "D:\work;E:\code" (semicolon-separated) or save scanRoots in config.json.'; return }
128
+ Write-Info ("scanning: " + ($roots -join ' '))
129
+ $found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
130
+ if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
131
+ $reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
132
+ # One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
133
+ $offered = @($found | Select-Object -First 200)
134
+ if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
135
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
136
+ $i = 1; $total = [long]0
137
+ foreach ($f in $offered) {
138
+ Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
139
+ $reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
140
+ Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
141
+ $total += $f.Bytes; $i++
142
+ }
143
+ Write-Info ("total: " + (Format-Bytes $total))
144
+ if (-not $ws.NoReport) {
145
+ $out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
146
+ try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
147
+ }
148
+ $picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
149
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
150
+ if (-not $ws.DryRun) {
151
+ # Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
152
+ # An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
153
+ if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
154
+ }
155
+ foreach ($k in $picks) {
156
+ $f = $offered[$k - 1]
157
+ $r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
158
+ if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
159
+ }
160
+ }