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,239 +1,242 @@
1
- # runner.ps1 - section dispatch, batch policy enforcement, the read-only scan mode and the summary.
2
-
3
- function Get-SectionIdList {
4
- <# .SYNOPSIS Parse "1,3,5-7" into section ids; validates against the catalogue. #>
5
- param([string] $Text)
6
- $ids = New-Object System.Collections.Generic.SortedSet[int]
7
- foreach ($part in ($Text -split ',')) {
8
- $part = $part.Trim()
9
- if (-not $part) { continue }
10
- if ($part -match '^(\d+)-(\d+)$') {
11
- $a = [int]$Matches[1]; $b = [int]$Matches[2]
12
- if ($a -gt $b) { $t = $a; $a = $b; $b = $t }
13
- for ($i = $a; $i -le $b; $i++) { if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" } }
14
- } elseif ($part -match '^\d+$') {
15
- $i = [int]$part
16
- if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" }
17
- } else {
18
- Write-Warn "cannot parse section '$part' - ignored"
19
- }
20
- }
21
- return @($ids)
22
- }
23
-
24
- function Get-RequestedSections {
25
- <# .SYNOPSIS Resolve --all / --only / --profile / --exclude into the ordered list to run. #>
26
- $ws = $Script:WS
27
- $ids = @()
28
- if ($ws.Mode -eq 'all') {
29
- $ids = @($Script:WS_SAFE_BATCH)
30
- if ($ws.IsAdmin) { $ids += $Script:WS_SAFE_BATCH_ADMIN }
31
- } elseif ($ws.OnlyList) {
32
- $ids = Get-SectionIdList $ws.OnlyList
33
- } elseif ($ws.Profile) {
34
- $ids = @($Script:WS_PROFILES[$ws.Profile])
35
- Write-Info "profile '$($ws.Profile)' = sections $($ids -join ',')"
36
- }
37
- if ($ws.Exclude) {
38
- $ex = Get-SectionIdList $ws.Exclude
39
- $ids = @($ids | Where-Object { $ex -notcontains $_ })
40
- Write-Info "after --exclude: sections $($ids -join ',')"
41
- }
42
- return @($ids | Sort-Object -Unique)
43
- }
44
-
45
- function Get-MachineProgressLine {
46
- <# .SYNOPSIS The progress-line format a --json caller parses. Kept pure so the self-test can round-trip it. #>
47
- param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
48
- $line = "##$Script:WS_NAME section=$Section event=$Stage"
49
- if ($Stage -eq 'end') { $line += " status=$Status freed_bytes=$Freed" }
50
- return $line
51
- }
52
-
53
- function Write-MachineProgress {
54
- <# .SYNOPSIS One machine-readable progress line on stderr, --json mode only, so a GUI need not parse the log.
55
- Never on stdout: --json promises exactly one stdout line. #>
56
- param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
57
- if (-not $Script:WS.JsonMode) { return }
58
- [Console]::Error.WriteLine((Get-MachineProgressLine -Section $Section -Stage $Stage -Status $Status -Freed $Freed))
59
- }
60
-
61
- function Add-JsonCandidate {
62
- <# .SYNOPSIS Record one item an interactive section offered, so --json callers can present the same list. #>
63
- param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = '')
64
- if (-not $Script:WS.JsonMode) { return }
65
- $Script:WS.Candidates += [ordered]@{ section = $Section; index = $Index; path = $Path; bytes = $Bytes; idle_days = $IdleDays; project = $Project }
66
- }
67
-
68
- function Invoke-SectionById {
69
- <# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
70
- param([int] $Id)
71
- $ws = $Script:WS
72
- $sec = Get-Section $Id
73
- if (-not $sec) { Write-Warn "no section $Id"; return }
74
- $title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
75
- Write-Box $title
76
- Write-MachineProgress -Section $Id -Stage 'start'
77
- # Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
78
- if ($ws.BatchMode -and -not $ws.DryRun) {
79
- if ($sec.Batch -eq 'interactive' -and -not $ws.SelectActive) {
80
- Write-Warn "section $Id is interactive-only: it needs a person at the keyboard, or a selection passed with --select / --select-file."
81
- $ws.Refusals += "section $Id (interactive-only)"
82
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'interactive-only section in batch mode'
83
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
84
- return
85
- }
86
- if ($sec.Batch -eq 'interactive') {
87
- Write-Note "section $Id runs unattended because a selection was supplied (--select / --select-file): a person chose these."
88
- }
89
- if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
90
- Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
91
- $ws.Refusals += "section $Id (deep, add --i-understand-deep)"
92
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'deep section without --i-understand-deep'
93
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
94
- return
95
- }
96
- }
97
- if ($sec.Admin -and -not $ws.IsAdmin) {
98
- Write-Warn "section $Id needs Administrator rights - skipped."
99
- Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
100
- $ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
101
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
102
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
103
- return
104
- }
105
- if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
106
- Write-Info "developer mode is off - section $Id (developer-only data) skipped."
107
- Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
108
- Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
109
- return
110
- }
111
- $ws.SectionFreed = [long]0
112
- $before = Get-SystemDriveFree
113
- $status = 'ran'
114
- if ($ws.DryRun) { $status = 'dry-run' }
115
- try {
116
- & $sec.Fn
117
- } catch {
118
- $status = 'failed'
119
- Write-Err "section $Id failed: $($_.Exception.Message)"
120
- Write-LogLine ("section $Id error: " + $_.ScriptStackTrace)
121
- $ws.ExitCode = $Script:WS_EXIT_FAIL
122
- }
123
- $after = Get-SystemDriveFree
124
- $delta = $after - $before
125
- if ($delta -lt 0) { $delta = 0 }
126
- $freed = $ws.SectionFreed
127
- if ($ws.DryRun) {
128
- if ($freed -gt 0) { Write-UiLine (" [dry-run] this section would free about {0}" -f (Format-Bytes $freed)) 'Magenta' }
129
- } elseif ($freed -gt 0 -or $delta -gt 0) {
130
- Write-UiLine (" {0} this section freed {1} (system drive gained {2}) running total: {3}" -f $ws.Glyph.ok, (Format-Bytes $freed), (Format-Bytes $delta), (Format-Bytes $ws.TotalFreed)) 'Green'
131
- } else {
132
- Write-Note 'no measurable change in this section'
133
- }
134
- Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
135
- Write-MachineProgress -Section $Id -Stage 'end' -Status $status -Freed $freed
136
- }
137
-
138
- function Invoke-BatchMode {
139
- <# .SYNOPSIS --all / --only / --profile #>
140
- $ws = $Script:WS
141
- $ids = @(Get-RequestedSections)
142
- if ($ids.Count -eq 0) { Write-Err 'no sections to run'; $ws.ExitCode = $Script:WS_EXIT_USAGE; return }
143
- Write-Banner
144
- Show-RunHeader
145
- Write-Info "running sections: $($ids -join ',')"
146
- foreach ($id in $ids) { Invoke-SectionById -Id $id }
147
- Show-SessionSummary
148
- if ($ws.Refusals.Count -gt 0 -and $ws.Mode -eq 'only' -and $ws.ExitCode -eq 0) { $ws.ExitCode = $Script:WS_EXIT_REFUSED }
149
- }
150
-
151
- function Show-RunHeader {
152
- $ws = $Script:WS
153
- $strategy = "prune files idle $($ws.Days)+ days (temp: $($ws.TempDays)+ days)"
154
- if ($ws.PurgeAll) { $strategy = 'FULL PURGE of cache targets' }
155
- $dev = 'off'
156
- if ($ws.Developer) { $dev = "on ($($ws.DeveloperSource))" }
157
- $adm = 'no'
158
- if ($ws.IsAdmin) { $adm = 'yes' }
159
- Write-Info "mode: $($ws.Mode) strategy: $strategy developer: $dev elevated: $adm auto-yes: $($ws.Yes)"
160
- if ($ws.DryRun) { Write-UiLine ' DRY-RUN: nothing will be deleted; sizes below are estimates of what a real run would remove.' 'Magenta' }
161
- Write-Info "log: $($ws.LogFile)"
162
- }
163
-
164
- function Invoke-ScanMode {
165
- <# .SYNOPSIS --scan: health report + every target's size + the read-only personal scanners. Deletes nothing. #>
166
- $ws = $Script:WS
167
- Write-Banner
168
- Write-Info 'read-only scan - nothing is deleted'
169
- if (Get-Command Invoke-Section00 -ErrorAction SilentlyContinue) { Invoke-Section00 }
170
- Write-Box 'Targets on disk' 'What each section can reach, and how much it currently holds'
171
- $null = Show-ScanTable
172
- if (Get-Command Show-PersonalScan -ErrorAction SilentlyContinue) { Show-PersonalScan }
173
- Add-ReportStep -Section -1 -Title 'Read-only scan' -Status 'ran'
174
- Show-SessionSummary
175
- }
176
-
177
- function Show-SessionSummary {
178
- $ws = $Script:WS
179
- Write-Box 'Session summary'
180
- if ($ws.DryRun) {
181
- Write-Kv 'Mode:' 'DRY-RUN - nothing was deleted'
182
- Write-Kv 'Would free (estimate):' (Format-Bytes $ws.TotalEstimated)
183
- } else {
184
- Write-Kv 'Reclaimed:' (Format-Bytes $ws.TotalFreed)
185
- }
186
- $secs = [int][math]::Round(((Get-Date).Ticks - $ws.Report.startedTicks) / 10000000)
187
- Write-Kv 'Duration:' (Format-Duration $secs)
188
- $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
189
- $skipped = @($ws.Report.steps | Where-Object { $_.status -notin 'ran', 'dry-run' }).Count
190
- Write-Kv 'Sections run / skipped:' "$ran / $skipped"
191
- Write-Kv 'Log:' $ws.LogFile
192
- $rep = Save-Report
193
- if ($rep) { Write-Kv 'Report (JSON):' $rep } elseif ($ws.NoReport) { Write-Kv 'Report:' '(disabled via --no-report)' }
194
- Write-UiLine '' 'Gray'
195
- Show-DriveTable
196
- if ($ws.Refusals.Count -gt 0) {
197
- Write-UiLine '' 'Gray'
198
- Write-Warn 'refused in batch mode:'
199
- foreach ($r in $ws.Refusals) { Write-Note $r }
200
- }
201
- if ($ws.Hints.Count -gt 0) {
202
- Write-UiLine '' 'Gray'
203
- Write-Info 'next steps:'
204
- foreach ($h in ($ws.Hints | Sort-Object -Unique)) { Write-Note $h }
205
- }
206
- Write-UiLine '' 'Gray'
207
- Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
208
- Send-RunNotification
209
- if ($ws.JsonMode) { Write-JsonSummary }
210
- }
211
-
212
- function Get-JsonSummary {
213
- <# .SYNOPSIS The --json document (ordered), kept apart from the printer so the self-test can parse it. #>
214
- $ws = $Script:WS
215
- return [ordered]@{
216
- tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
217
- elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
218
- freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
219
- sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
220
- # candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
221
- candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
222
- refusals = @($ws.Refusals); log_file = $ws.LogFile; report_file = $ws.ReportFile
223
- }
224
- }
225
-
226
- function Get-CatalogueJson {
227
- <# .SYNOPSIS --list --json: the section catalogue, so a front end reads it instead of hard-coding it. #>
228
- return [ordered]@{
229
- tool = $Script:WS_NAME; version = (Get-ToolVersion)
230
- sections = @($Script:WS_SECTIONS | ForEach-Object { [ordered]@{ id = $_.Id; key = $_.Key; title = $_.Title; tier = $_.Tier; admin = [bool]$_.Admin; batch = $_.Batch; dev = [bool]$_.Dev } })
231
- safe_batch = @($Script:WS_SAFE_BATCH); safe_batch_admin = @($Script:WS_SAFE_BATCH_ADMIN)
232
- profiles = ([ordered]@{} + $Script:WS_PROFILES); walkthrough = @($Script:WS_WALKTHROUGH); walkthrough_admin = @($Script:WS_WALKTHROUGH_ADMIN)
233
- }
234
- }
235
-
236
- function Write-JsonSummary {
237
- <# .SYNOPSIS --json: the only line written to stdout. #>
238
- [Console]::Out.WriteLine(((Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress))
239
- }
1
+ # runner.ps1 - section dispatch, batch policy enforcement, the read-only scan mode and the summary.
2
+
3
+ function Get-SectionIdList {
4
+ <# .SYNOPSIS Parse "1,3,5-7" into section ids; validates against the catalogue. #>
5
+ param([string] $Text)
6
+ $ids = New-Object System.Collections.Generic.SortedSet[int]
7
+ foreach ($part in ($Text -split ',')) {
8
+ $part = $part.Trim()
9
+ if (-not $part) { continue }
10
+ if ($part -match '^(\d+)-(\d+)$') {
11
+ $a = [int]$Matches[1]; $b = [int]$Matches[2]
12
+ if ($a -gt $b) { $t = $a; $a = $b; $b = $t }
13
+ for ($i = $a; $i -le $b; $i++) { if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" } }
14
+ } elseif ($part -match '^\d+$') {
15
+ $i = [int]$part
16
+ if (Get-Section $i) { $null = $ids.Add($i) } else { Write-Warn "no section $i - ignored" }
17
+ } else {
18
+ Write-Warn "cannot parse section '$part' - ignored"
19
+ }
20
+ }
21
+ return @($ids)
22
+ }
23
+
24
+ function Get-RequestedSections {
25
+ <# .SYNOPSIS Resolve --all / --only / --profile / --exclude into the ordered list to run. #>
26
+ $ws = $Script:WS
27
+ $ids = @()
28
+ if ($ws.Mode -eq 'all') {
29
+ $ids = @($Script:WS_SAFE_BATCH)
30
+ if ($ws.IsAdmin) { $ids += $Script:WS_SAFE_BATCH_ADMIN }
31
+ } elseif ($ws.OnlyList) {
32
+ $ids = Get-SectionIdList $ws.OnlyList
33
+ } elseif ($ws.Profile) {
34
+ $ids = @($Script:WS_PROFILES[$ws.Profile])
35
+ Write-Info "profile '$($ws.Profile)' = sections $($ids -join ',')"
36
+ }
37
+ if ($ws.Exclude) {
38
+ $ex = Get-SectionIdList $ws.Exclude
39
+ $ids = @($ids | Where-Object { $ex -notcontains $_ })
40
+ Write-Info "after --exclude: sections $($ids -join ',')"
41
+ }
42
+ return @($ids | Sort-Object -Unique)
43
+ }
44
+
45
+ function Get-MachineProgressLine {
46
+ <# .SYNOPSIS The progress-line format a --json caller parses. Kept pure so the self-test can round-trip it. #>
47
+ param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
48
+ $line = "##$Script:WS_NAME section=$Section event=$Stage"
49
+ if ($Stage -eq 'end') { $line += " status=$Status freed_bytes=$Freed" }
50
+ return $line
51
+ }
52
+
53
+ function Write-MachineProgress {
54
+ <# .SYNOPSIS One machine-readable progress line on stderr, --json mode only, so a GUI need not parse the log.
55
+ Never on stdout: --json promises exactly one stdout line. #>
56
+ param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
57
+ if (-not $Script:WS.JsonMode) { return }
58
+ [Console]::Error.WriteLine((Get-MachineProgressLine -Section $Section -Stage $Stage -Status $Status -Freed $Freed))
59
+ }
60
+
61
+ function Add-JsonCandidate {
62
+ <# .SYNOPSIS Record one item an interactive section offered, so --json callers can present the same list. #>
63
+ param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = '')
64
+ if (-not $Script:WS.JsonMode) { return }
65
+ $Script:WS.Candidates += [ordered]@{ section = $Section; index = $Index; path = $Path; bytes = $Bytes; idle_days = $IdleDays; project = $Project }
66
+ }
67
+
68
+ function Invoke-SectionById {
69
+ <# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
70
+ param([int] $Id)
71
+ $ws = $Script:WS
72
+ $sec = Get-Section $Id
73
+ if (-not $sec) { Write-Warn "no section $Id"; return }
74
+ $title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
75
+ Write-Box $title
76
+ Write-MachineProgress -Section $Id -Stage 'start'
77
+ # Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
78
+ if ($ws.BatchMode -and -not $ws.DryRun) {
79
+ if ($sec.Batch -eq 'interactive' -and -not $ws.SelectActive) {
80
+ Write-Warn "section $Id is interactive-only: it needs a person at the keyboard, or a selection passed with --select / --select-file."
81
+ $ws.Refusals += "section $Id (interactive-only)"
82
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'interactive-only section in batch mode'
83
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
84
+ return
85
+ }
86
+ if ($sec.Batch -eq 'interactive') {
87
+ Write-Note "section $Id runs unattended because a selection was supplied (--select / --select-file): a person chose these."
88
+ }
89
+ if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
90
+ Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
91
+ $ws.Refusals += "section $Id (deep, add --i-understand-deep)"
92
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'deep section without --i-understand-deep'
93
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
94
+ return
95
+ }
96
+ }
97
+ if ($sec.Admin -and -not $ws.IsAdmin) {
98
+ Write-Warn "section $Id needs Administrator rights - skipped."
99
+ Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
100
+ $ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
101
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
102
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
103
+ return
104
+ }
105
+ if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
106
+ Write-Info "developer mode is off - section $Id (developer-only data) skipped."
107
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
108
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
109
+ return
110
+ }
111
+ $ws.SectionFreed = [long]0
112
+ $before = Get-SystemDriveFree
113
+ $status = 'ran'
114
+ if ($ws.DryRun) { $status = 'dry-run' }
115
+ try {
116
+ & $sec.Fn
117
+ } catch {
118
+ $status = 'failed'
119
+ Write-Err "section $Id failed: $($_.Exception.Message)"
120
+ Write-LogLine ("section $Id error: " + $_.ScriptStackTrace)
121
+ $ws.ExitCode = $Script:WS_EXIT_FAIL
122
+ }
123
+ $after = Get-SystemDriveFree
124
+ $delta = $after - $before
125
+ if ($delta -lt 0) { $delta = 0 }
126
+ $freed = $ws.SectionFreed
127
+ if ($ws.DryRun) {
128
+ if ($freed -gt 0) { Write-UiLine (" [dry-run] this section would reclaim about {0}" -f (Format-Bytes $freed)) 'Magenta' }
129
+ } elseif ($freed -gt 0 -or $delta -gt 0) {
130
+ Write-UiLine (" {0} this section reclaimed {1} (system drive gained {2}) running total: {3}" -f $ws.Glyph.ok, (Format-Bytes $freed), (Format-Bytes $delta), (Format-Bytes $ws.TotalFreed)) 'Green'
131
+ } else {
132
+ Write-Note 'no measurable change in this section'
133
+ }
134
+ Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
135
+ Write-MachineProgress -Section $Id -Stage 'end' -Status $status -Freed $freed
136
+ }
137
+
138
+ function Invoke-BatchMode {
139
+ <# .SYNOPSIS --all / --only / --profile #>
140
+ $ws = $Script:WS
141
+ $ids = @(Get-RequestedSections)
142
+ if ($ids.Count -eq 0) { Write-Err 'no sections to run - check --only / --profile against windowsweep --list'; $ws.ExitCode = $Script:WS_EXIT_USAGE; return }
143
+ Write-Banner
144
+ Show-RunHeader
145
+ Write-Info "running sections: $($ids -join ',')"
146
+ foreach ($id in $ids) { Invoke-SectionById -Id $id }
147
+ Show-SessionSummary
148
+ if ($ws.Refusals.Count -gt 0 -and $ws.Mode -eq 'only' -and $ws.ExitCode -eq 0) { $ws.ExitCode = $Script:WS_EXIT_REFUSED }
149
+ }
150
+
151
+ function Show-RunHeader {
152
+ $ws = $Script:WS
153
+ $strategy = "prune files idle $($ws.Days)+ days (temp: $($ws.TempDays)+ days)"
154
+ if ($ws.PurgeAll) { $strategy = 'FULL PURGE of cache targets' }
155
+ $dev = 'off'
156
+ if ($ws.Developer) { $dev = "on ($($ws.DeveloperSource))" }
157
+ $adm = 'no'
158
+ if ($ws.IsAdmin) { $adm = 'yes' }
159
+ Write-Info "mode: $($ws.Mode) strategy: $strategy developer: $dev elevated: $adm auto-yes: $($ws.Yes)"
160
+ if ($ws.DryRun) { Write-UiLine ' DRY-RUN: nothing will be deleted; sizes below are estimates of what a real run would remove.' 'Magenta' }
161
+ Write-Info "log: $($ws.LogFile)"
162
+ }
163
+
164
+ function Invoke-ScanMode {
165
+ <# .SYNOPSIS --scan: health report + every target's size + the read-only personal scanners. Deletes nothing. #>
166
+ $ws = $Script:WS
167
+ Write-Banner
168
+ Write-Info 'scan only - nothing is deleted; a log and a report are still written'
169
+ if (Get-Command Invoke-Section00 -ErrorAction SilentlyContinue) { Invoke-Section00 }
170
+ Write-Box 'Targets on disk' 'What each section can reach, and how much it currently holds'
171
+ $null = Show-ScanTable
172
+ if (Get-Command Show-PersonalScan -ErrorAction SilentlyContinue) { Show-PersonalScan }
173
+ Add-ReportStep -Section -1 -Title 'Scan' -Status 'ran'
174
+ Show-SessionSummary
175
+ }
176
+
177
+ function Show-SessionSummary {
178
+ $ws = $Script:WS
179
+ Write-Box 'Session summary'
180
+ if ($ws.DryRun) {
181
+ Write-Kv 'Mode:' 'DRY-RUN - nothing was deleted'
182
+ Write-Kv 'Would reclaim (est.):' (Format-Bytes $ws.TotalEstimated)
183
+ } else {
184
+ Write-Kv 'Reclaimed:' (Format-Bytes $ws.TotalFreed)
185
+ }
186
+ $secs = [int][math]::Round(((Get-Date).Ticks - $ws.Report.startedTicks) / 10000000)
187
+ Write-Kv 'Duration:' (Format-Duration $secs)
188
+ $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
189
+ $skipped = @($ws.Report.steps | Where-Object { $_.status -notin 'ran', 'dry-run' }).Count
190
+ Write-Kv 'Sections run / skipped:' "$ran / $skipped"
191
+ Write-Kv 'Log:' $ws.LogFile
192
+ $rep = Save-Report
193
+ if ($rep) { Write-Kv 'Report (JSON):' $rep } elseif ($ws.NoReport) { Write-Kv 'Report:' '(disabled via --no-report)' }
194
+ Write-UiLine '' 'Gray'
195
+ Show-DriveTable
196
+ if ($ws.Refusals.Count -gt 0) {
197
+ Write-UiLine '' 'Gray'
198
+ Write-Warn 'refused in batch mode:'
199
+ foreach ($r in $ws.Refusals) { Write-Note $r }
200
+ }
201
+ if ($ws.Hints.Count -gt 0) {
202
+ Write-UiLine '' 'Gray'
203
+ Write-Info 'next steps:'
204
+ foreach ($h in ($ws.Hints | Sort-Object -Unique)) { Write-Note $h }
205
+ }
206
+ Write-UiLine '' 'Gray'
207
+ Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
208
+ Send-RunNotification
209
+ if ($ws.JsonMode) { Write-JsonSummary }
210
+ }
211
+
212
+ function Get-JsonSummary {
213
+ <# .SYNOPSIS The --json document (ordered), kept apart from the printer so the self-test can parse it. #>
214
+ $ws = $Script:WS
215
+ return [ordered]@{
216
+ tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
217
+ elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
218
+ freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
219
+ sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
220
+ # candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
221
+ candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
222
+ refusals = @($ws.Refusals); excluded = @($ws.Excluded); log_file = $ws.LogFile; report_file = $ws.ReportFile
223
+ }
224
+ }
225
+
226
+ function Get-CatalogueJson {
227
+ <# .SYNOPSIS --list --json: the section catalogue, so a front end reads it instead of hard-coding it. #>
228
+ return [ordered]@{
229
+ tool = $Script:WS_NAME; version = (Get-ToolVersion)
230
+ sections = @($Script:WS_SECTIONS | ForEach-Object { [ordered]@{ id = $_.Id; key = $_.Key; title = $_.Title; tier = $_.Tier; admin = [bool]$_.Admin; batch = $_.Batch; dev = [bool]$_.Dev } })
231
+ safe_batch = @($Script:WS_SAFE_BATCH); safe_batch_admin = @($Script:WS_SAFE_BATCH_ADMIN)
232
+ # The same two lists --list-targets prints, so a front end shows the engine's own promise rather than
233
+ # a copy of it that can age. Subtrees is built by Initialize-Safety from this machine's real folders.
234
+ protected = [ordered]@{ subtrees = @($Script:WS_PROTECT.Subtrees); categories = @($Script:WS_PROTECT_CATEGORIES) }
235
+ profiles = ([ordered]@{} + $Script:WS_PROFILES); walkthrough = @($Script:WS_WALKTHROUGH); walkthrough_admin = @($Script:WS_WALKTHROUGH_ADMIN)
236
+ }
237
+ }
238
+
239
+ function Write-JsonSummary {
240
+ <# .SYNOPSIS --json: the only line written to stdout. #>
241
+ [Console]::Out.WriteLine(((Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress))
242
+ }