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,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
+ }