windowsweep 1.0.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.
@@ -0,0 +1,212 @@
1
+ # reports.ps1 - list, view, export (Markdown / HTML) and summarise past JSON reports. No external tools needed.
2
+
3
+ function Get-ReportFiles {
4
+ $dir = $Script:WS.ReportsDir
5
+ if (-not (Test-Path -LiteralPath $dir)) { return @() }
6
+ return @(Get-ChildItem -LiteralPath $dir -Filter 'report-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
7
+ }
8
+
9
+ function Read-Report {
10
+ param([string] $Path)
11
+ try { return (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop) } catch { return $null }
12
+ }
13
+
14
+ function Show-ReportList {
15
+ $files = @(Get-ReportFiles)
16
+ if ($files.Count -eq 0) { Write-Info 'no reports yet - run a cleanup first'; return @() }
17
+ Write-UiLine (" {0,3} {1,-34} {2,8} {3,-17} {4,-12} {5}" -f '#', 'REPORT', 'SIZE', 'STARTED', 'RECLAIMED', 'MODE') 'White'
18
+ $i = 1
19
+ foreach ($f in $files) {
20
+ $r = Read-Report $f.FullName
21
+ $started = '?'; $rec = '?'; $mode = '?'
22
+ if ($r) {
23
+ $started = ([string]$r.meta.started_at).Substring(0, [math]::Min(16, ([string]$r.meta.started_at).Length)).Replace('T', ' ')
24
+ $rec = $r.totals.total_reclaimed_human
25
+ if ($r.meta.dry_run) { $rec = "~$($r.totals.total_estimated_human) (dry)" }
26
+ $mode = $r.meta.mode
27
+ }
28
+ Write-UiLine (" {0,3} {1,-34} {2,8} {3,-17} {4,-12} {5}" -f $i, $f.Name, (Format-Bytes $f.Length), $started, $rec, $mode) 'Gray'
29
+ $i++
30
+ }
31
+ return $files
32
+ }
33
+
34
+ function ConvertTo-HtmlText { param([string] $Text) return [System.Net.WebUtility]::HtmlEncode([string]$Text) }
35
+
36
+ function Convert-ReportToMarkdown {
37
+ param([string] $Json, [string] $Out = '')
38
+ $r = Read-Report $Json
39
+ if (-not $r) { Write-Err "cannot read $Json"; return $null }
40
+ if (-not $Out) { $Out = [IO.Path]::ChangeExtension($Json, '.md') }
41
+ $dry = 'no'
42
+ if ($r.meta.dry_run) { $dry = 'yes' }
43
+ $l = @()
44
+ $l += "# $Script:WS_NAME session report"; $l += ''
45
+ $l += "_Generated from ``$([IO.Path]::GetFileName($Json))``_"; $l += ''
46
+ $l += '## Overview'; $l += ''; $l += '| Field | Value |'; $l += '|---|---|'
47
+ $l += "| Started | $($r.meta.started_at) |"; $l += "| Finished | $($r.meta.finished_at) |"; $l += "| Duration | $($r.meta.duration_seconds) s |"
48
+ $l += "| Host | $($r.meta.host) |"; $l += "| User | $($r.meta.user) |"; $l += "| Windows | $($r.meta.os) |"; $l += "| Mode | $($r.meta.mode) |"
49
+ $l += "| Dry-run | $dry |"; $l += "| Elevated | $($r.meta.elevated) |"; $l += "| Developer mode | $($r.meta.developer_mode) |"; $l += "| Idle window | $($r.meta.idle_days) days |"
50
+ $l += ''; $l += '## Result'; $l += ''
51
+ if ($r.meta.dry_run) { $l += "**Would free (estimate): $($r.totals.total_estimated_human)** " } else { $l += "**Reclaimed: $($r.totals.total_reclaimed_human)** ($($r.totals.total_reclaimed_bytes) bytes) " }
52
+ $l += "Sections run: $($r.totals.steps_run) - skipped/refused: $($r.totals.steps_skipped)"; $l += ''
53
+ $l += '## Drives'; $l += ''; $l += '| Drive | Before free | After free | Size |'; $l += '|---|---|---|---|'
54
+ foreach ($b in $r.disk.before) {
55
+ $a = $r.disk.after | Where-Object { $_.drive -eq $b.drive } | Select-Object -First 1
56
+ $afterFree = '?'
57
+ if ($a) { $afterFree = Format-Bytes $a.free_bytes }
58
+ $l += "| $($b.drive) | $(Format-Bytes $b.free_bytes) | $afterFree | $(Format-Bytes $b.size_bytes) |"
59
+ }
60
+ $l += ''; $l += '## Steps'; $l += ''; $l += '| # | Section | Title | Status | Freed |'; $l += '|---:|---:|---|---|---:|'
61
+ foreach ($s in $r.steps) { $l += "| $($s.n) | $($s.section) | $($s.title) | $($s.status) | $(Format-Bytes $s.freed_bytes) |" }
62
+ $l += ''; $l += "_${Script:WS_NAME} v$($r.meta.tool_version) by $($r.credits.author.name) - $($r.credits.tool_homepage)_"
63
+ [IO.File]::WriteAllLines($Out, $l)
64
+ Write-Ok "wrote $Out"
65
+ return $Out
66
+ }
67
+
68
+ function Convert-ReportToHtml {
69
+ param([string] $Json, [string] $Out = '')
70
+ $r = Read-Report $Json
71
+ if (-not $r) { Write-Err "cannot read $Json"; return $null }
72
+ if (-not $Out) { $Out = [IO.Path]::ChangeExtension($Json, '.html') }
73
+ $headline = "Reclaimed"; $big = $r.totals.total_reclaimed_human
74
+ if ($r.meta.dry_run) { $headline = 'Would free (dry-run estimate)'; $big = $r.totals.total_estimated_human }
75
+ $rows = ''
76
+ foreach ($s in $r.steps) {
77
+ $cls = ([string]$s.status) -replace '[^a-z-]', ''
78
+ $rows += "<tr class=`"row-$cls`"><td>$($s.n)</td><td>$($s.section)</td><td>$(ConvertTo-HtmlText $s.title)</td><td><span class=`"badge $cls`">$(ConvertTo-HtmlText $s.status)</span></td><td class=`"num`">$(ConvertTo-HtmlText (Format-Bytes $s.freed_bytes))</td></tr>`n"
79
+ }
80
+ $drives = ''
81
+ foreach ($b in $r.disk.before) {
82
+ $a = $r.disk.after | Where-Object { $_.drive -eq $b.drive } | Select-Object -First 1
83
+ $afterFree = '?'
84
+ if ($a) { $afterFree = Format-Bytes $a.free_bytes }
85
+ $drives += "<tr><td>$(ConvertTo-HtmlText $b.drive)</td><td class=`"num`">$(Format-Bytes $b.size_bytes)</td><td class=`"num`">$(Format-Bytes $b.free_bytes)</td><td class=`"num`">$afterFree</td></tr>`n"
86
+ }
87
+ $html = @"
88
+ <!doctype html>
89
+ <html lang="en">
90
+ <head>
91
+ <meta charset="utf-8">
92
+ <meta name="viewport" content="width=device-width, initial-scale=1">
93
+ <title>$Script:WS_NAME report - $(ConvertTo-HtmlText $r.meta.started_at)</title>
94
+ <style>
95
+ :root{--fg:#1c1c1f;--bg:#fafafa;--muted:#7a7a85;--ok:#0a7d54;--warn:#b8860b;--line:#e6e6ea;--card:#fff;--accent:#0b6bcb}
96
+ @media (prefers-color-scheme:dark){:root{--fg:#eaeaea;--bg:#16161a;--muted:#9d9da8;--line:#2a2a30;--card:#1f1f25;--accent:#5aa9ff}}
97
+ *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 'Segoe UI',system-ui,sans-serif}
98
+ .wrap{max-width:960px;margin:2rem auto;padding:0 1.25rem}h1{font-size:1.6rem;margin:0 0 .3rem}
99
+ h2{font-size:1.05rem;margin:2.2rem 0 .7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
100
+ .subtitle{color:var(--muted);margin:0 0 1.5rem}.totals{display:flex;align-items:baseline;gap:1rem;padding:1.5rem 1.75rem;border:1px solid var(--line);border-radius:12px;background:var(--card);margin:1rem 0}
101
+ .totals .big{font-size:2.5rem;font-weight:800;color:var(--ok);line-height:1}.totals .label{color:var(--muted);text-transform:uppercase;font-size:.75rem;letter-spacing:.08em}
102
+ dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.6rem 1.5rem;margin:0;padding:1rem 1.25rem;border:1px solid var(--line);border-radius:10px;background:var(--card)}
103
+ dt{color:var(--muted);font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}dd{margin:.1rem 0 0;font-weight:600;word-break:break-all}
104
+ table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
105
+ th,td{text-align:left;padding:.55rem .85rem;border-bottom:1px solid var(--line)}tr:last-child td{border-bottom:none}
106
+ th{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;background:var(--bg)}td.num{text-align:right;font-variant-numeric:tabular-nums}
107
+ .badge{display:inline-block;padding:.12em .5em;border-radius:999px;font-size:.75em;font-weight:600;text-transform:uppercase}
108
+ .badge.ran,.badge.dry-run{background:rgba(10,125,84,.12);color:var(--ok)}.badge.skipped,.badge.refused{background:rgba(184,134,11,.12);color:var(--warn)}.badge.failed{background:rgba(192,57,43,.12);color:#c0392b}
109
+ footer{margin:3rem 0 1rem;color:var(--muted);font-size:.85rem;text-align:center}a{color:var(--accent)}
110
+ </style>
111
+ </head>
112
+ <body><div class="wrap">
113
+ <h1>$Script:WS_NAME session report</h1>
114
+ <p class="subtitle">$(ConvertTo-HtmlText $r.meta.started_at) to $(ConvertTo-HtmlText $r.meta.finished_at) - $($r.meta.duration_seconds)s - $(ConvertTo-HtmlText $r.meta.host)</p>
115
+ <div class="totals"><div><div class="label">$headline</div><div class="big">$(ConvertTo-HtmlText $big)</div></div>
116
+ <div style="margin-left:auto;text-align:right;color:var(--muted)"><div><strong>$($r.totals.steps_run)</strong> sections run</div><div><strong>$($r.totals.steps_skipped)</strong> skipped</div></div></div>
117
+ <h2>Run</h2>
118
+ <dl><div><dt>Mode</dt><dd>$(ConvertTo-HtmlText $r.meta.mode)</dd></div><div><dt>Dry-run</dt><dd>$($r.meta.dry_run)</dd></div><div><dt>Elevated</dt><dd>$($r.meta.elevated)</dd></div>
119
+ <div><dt>Developer mode</dt><dd>$($r.meta.developer_mode)</dd></div><div><dt>Idle window</dt><dd>$($r.meta.idle_days) days</dd></div><div><dt>Windows</dt><dd>$(ConvertTo-HtmlText $r.meta.os)</dd></div>
120
+ <div><dt>Log file</dt><dd><code>$(ConvertTo-HtmlText $r.meta.log_file)</code></dd></div></dl>
121
+ <h2>Sections</h2>
122
+ <table><thead><tr><th>#</th><th>Section</th><th>Title</th><th>Status</th><th class="num">Freed</th></tr></thead><tbody>
123
+ $rows </tbody></table>
124
+ <h2>Drives</h2>
125
+ <table><thead><tr><th>Drive</th><th class="num">Size</th><th class="num">Free before</th><th class="num">Free after</th></tr></thead><tbody>
126
+ $drives </tbody></table>
127
+ <footer>Generated from <code>$(ConvertTo-HtmlText ([IO.Path]::GetFileName($Json)))</code> by <a href="$Script:WS_REPO">$Script:WS_NAME</a> v$(ConvertTo-HtmlText $r.meta.tool_version) - $(ConvertTo-HtmlText $r.credits.author.name)</footer>
128
+ </div></body></html>
129
+ "@
130
+ [IO.File]::WriteAllText($Out, $html, (New-Object System.Text.UTF8Encoding($false)))
131
+ Write-Ok "wrote $Out"
132
+ return $Out
133
+ }
134
+
135
+ function Export-Reports {
136
+ <# .SYNOPSIS --export FMT ID. Returns an exit code. #>
137
+ param([string] $Format = 'both', [string] $Id = 'latest')
138
+ if ($Format -notin 'md', 'html', 'both') { Write-Err 'format must be md, html or both'; return $Script:WS_EXIT_USAGE }
139
+ $files = @(Get-ReportFiles)
140
+ if ($files.Count -eq 0) { Write-Err "no reports in $($Script:WS.ReportsDir)"; return $Script:WS_EXIT_FAIL }
141
+ $picked = @()
142
+ switch -Regex ($Id) {
143
+ '^all$' { $picked = $files }
144
+ '^latest$' { $picked = @($files[0]) }
145
+ '^\d+$' { $n = [int]$Id; if ($n -lt 1 -or $n -gt $files.Count) { Write-Err "index $Id out of range (1..$($files.Count))"; return $Script:WS_EXIT_USAGE }; $picked = @($files[$n - 1]) }
146
+ default { Write-Err 'ID must be a number, latest or all'; return $Script:WS_EXIT_USAGE }
147
+ }
148
+ foreach ($f in $picked) {
149
+ if ($Format -in 'md', 'both') { $null = Convert-ReportToMarkdown -Json $f.FullName }
150
+ if ($Format -in 'html', 'both') { $null = Convert-ReportToHtml -Json $f.FullName }
151
+ }
152
+ return $Script:WS_EXIT_OK
153
+ }
154
+
155
+ function Invoke-ReportsManager {
156
+ Write-Box 'Reports manager' $Script:WS.ReportsDir
157
+ $files = @(Show-ReportList)
158
+ if ($files.Count -eq 0) { return }
159
+ if (-not $Script:WS.Interactive) { Write-Note 'non-interactive: use --export md|html|both N|latest|all'; return }
160
+ Write-UiLine '' 'Gray'
161
+ Write-Plain ' cm N markdown ch N html cb N both all export everything v N view JSON o N open HTML d N delete q quit'
162
+ $answer = Read-Line ' > '
163
+ if ($null -eq $answer) { return }
164
+ $parts = $answer.Trim() -split '\s+'
165
+ $cmd = $parts[0].ToLowerInvariant()
166
+ $n = 0
167
+ if ($parts.Count -gt 1 -and $parts[1] -match '^\d+$') { $n = [int]$parts[1] }
168
+ $target = $null
169
+ if ($n -ge 1 -and $n -le $files.Count) { $target = $files[$n - 1].FullName }
170
+ switch ($cmd) {
171
+ 'cm' { if ($target) { $null = Convert-ReportToMarkdown -Json $target } else { Write-Err 'invalid index' } }
172
+ 'ch' { if ($target) { $null = Convert-ReportToHtml -Json $target } else { Write-Err 'invalid index' } }
173
+ 'cb' { if ($target) { $null = Convert-ReportToMarkdown -Json $target; $null = Convert-ReportToHtml -Json $target } else { Write-Err 'invalid index' } }
174
+ 'all' { foreach ($f in $files) { $null = Convert-ReportToMarkdown -Json $f.FullName; $null = Convert-ReportToHtml -Json $f.FullName } }
175
+ 'v' { if ($target) { Get-Content -LiteralPath $target | ForEach-Object { Write-Plain $_ } } else { Write-Err 'invalid index' } }
176
+ 'o' {
177
+ if ($target) {
178
+ $html = [IO.Path]::ChangeExtension($target, '.html')
179
+ if (-not (Test-Path -LiteralPath $html)) { $null = Convert-ReportToHtml -Json $target }
180
+ try { Start-Process $html | Out-Null; Write-Ok "opened $html" } catch { Write-Warn "could not open $html" }
181
+ } else { Write-Err 'invalid index' }
182
+ }
183
+ 'd' {
184
+ if ($target) {
185
+ if (Confirm-Ui -Prompt "Delete $([IO.Path]::GetFileName($target)) and its .md/.html siblings?" -Default 'n') {
186
+ foreach ($ext in '.json', '.md', '.html') { $p = [IO.Path]::ChangeExtension($target, $ext); if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } }
187
+ Write-Ok 'deleted'
188
+ }
189
+ } else { Write-Err 'invalid index' }
190
+ }
191
+ 'q' { return }
192
+ default { Write-Warn "unknown action: $cmd" }
193
+ }
194
+ }
195
+
196
+ function Show-Stats {
197
+ Write-Box 'Run history' $Script:WS.Home
198
+ $files = @(Get-ReportFiles)
199
+ if ($files.Count -eq 0) { Write-Info 'no runs recorded yet'; return }
200
+ $total = [long]0; $runs = 0; $dry = 0
201
+ foreach ($f in $files) {
202
+ $r = Read-Report $f.FullName
203
+ if (-not $r) { continue }
204
+ if ($r.meta.dry_run) { $dry++ } else { $runs++; $total += [long]$r.totals.total_reclaimed_bytes }
205
+ }
206
+ Write-Kv 'Real runs:' $runs
207
+ Write-Kv 'Dry-runs:' $dry
208
+ Write-Kv 'Reclaimed overall:' (Format-Bytes $total)
209
+ Write-Kv 'Latest report:' $files[0].FullName
210
+ $logs = Join-Path $Script:WS.Home 'logs'
211
+ if (Test-Path -LiteralPath $logs) { Write-Kv 'Logs on disk:' (Format-Bytes (Get-DirectoryBytes $logs)) }
212
+ }
@@ -0,0 +1,190 @@
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 Invoke-SectionById {
46
+ <# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
47
+ param([int] $Id)
48
+ $ws = $Script:WS
49
+ $sec = Get-Section $Id
50
+ if (-not $sec) { Write-Warn "no section $Id"; return }
51
+ $title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
52
+ Write-Box $title
53
+ # Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
54
+ if ($ws.BatchMode -and -not $ws.DryRun) {
55
+ if ($sec.Batch -eq 'interactive') {
56
+ Write-Warn "section $Id is interactive-only: it needs a person at the keyboard. Run it from the walkthrough or the menu."
57
+ $ws.Refusals += "section $Id (interactive-only)"
58
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'interactive-only section in batch mode'
59
+ return
60
+ }
61
+ if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
62
+ Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
63
+ $ws.Refusals += "section $Id (deep, add --i-understand-deep)"
64
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'deep section without --i-understand-deep'
65
+ return
66
+ }
67
+ }
68
+ if ($sec.Admin -and -not $ws.IsAdmin) {
69
+ Write-Warn "section $Id needs Administrator rights - skipped."
70
+ Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
71
+ $ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
72
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
73
+ return
74
+ }
75
+ if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
76
+ Write-Info "developer mode is off - section $Id (developer-only data) skipped."
77
+ Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
78
+ return
79
+ }
80
+ $ws.SectionFreed = [long]0
81
+ $before = Get-SystemDriveFree
82
+ $status = 'ran'
83
+ if ($ws.DryRun) { $status = 'dry-run' }
84
+ try {
85
+ & $sec.Fn
86
+ } catch {
87
+ $status = 'failed'
88
+ Write-Err "section $Id failed: $($_.Exception.Message)"
89
+ Write-Log ("section $Id error: " + $_.ScriptStackTrace)
90
+ $ws.ExitCode = $Script:WS_EXIT_FAIL
91
+ }
92
+ $after = Get-SystemDriveFree
93
+ $delta = $after - $before
94
+ if ($delta -lt 0) { $delta = 0 }
95
+ $freed = $ws.SectionFreed
96
+ if ($ws.DryRun) {
97
+ if ($freed -gt 0) { Write-UiLine (" [dry-run] this section would free about {0}" -f (Format-Bytes $freed)) 'Magenta' }
98
+ } elseif ($freed -gt 0 -or $delta -gt 0) {
99
+ 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'
100
+ } else {
101
+ Write-Note 'no measurable change in this section'
102
+ }
103
+ Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
104
+ }
105
+
106
+ function Invoke-BatchMode {
107
+ <# .SYNOPSIS --all / --only / --profile #>
108
+ $ws = $Script:WS
109
+ $ids = @(Get-RequestedSections)
110
+ if ($ids.Count -eq 0) { Write-Err 'no sections to run'; $ws.ExitCode = $Script:WS_EXIT_USAGE; return }
111
+ Write-Banner
112
+ Show-RunHeader
113
+ Write-Info "running sections: $($ids -join ',')"
114
+ foreach ($id in $ids) { Invoke-SectionById -Id $id }
115
+ Show-SessionSummary
116
+ if ($ws.Refusals.Count -gt 0 -and $ws.Mode -eq 'only' -and $ws.ExitCode -eq 0) { $ws.ExitCode = $Script:WS_EXIT_REFUSED }
117
+ }
118
+
119
+ function Show-RunHeader {
120
+ $ws = $Script:WS
121
+ $strategy = "prune files idle $($ws.Days)+ days (temp: $($ws.TempDays)+ days)"
122
+ if ($ws.PurgeAll) { $strategy = 'FULL PURGE of cache targets' }
123
+ $dev = 'off'
124
+ if ($ws.Developer) { $dev = "on ($($ws.DeveloperSource))" }
125
+ $adm = 'no'
126
+ if ($ws.IsAdmin) { $adm = 'yes' }
127
+ Write-Info "mode: $($ws.Mode) strategy: $strategy developer: $dev elevated: $adm auto-yes: $($ws.Yes)"
128
+ if ($ws.DryRun) { Write-UiLine ' DRY-RUN: nothing will be deleted; sizes below are estimates of what a real run would remove.' 'Magenta' }
129
+ Write-Info "log: $($ws.LogFile)"
130
+ }
131
+
132
+ function Invoke-ScanMode {
133
+ <# .SYNOPSIS --scan: health report + every target's size + the read-only personal scanners. Deletes nothing. #>
134
+ $ws = $Script:WS
135
+ Write-Banner
136
+ Write-Info 'read-only scan - nothing is deleted'
137
+ if (Get-Command Invoke-Section00 -ErrorAction SilentlyContinue) { Invoke-Section00 }
138
+ Write-Box 'Targets on disk' 'What each section can reach, and how much it currently holds'
139
+ $null = Show-ScanTable
140
+ if (Get-Command Show-PersonalScan -ErrorAction SilentlyContinue) { Show-PersonalScan }
141
+ Add-ReportStep -Section -1 -Title 'Read-only scan' -Status 'ran'
142
+ Show-SessionSummary
143
+ }
144
+
145
+ function Show-SessionSummary {
146
+ $ws = $Script:WS
147
+ Write-Box 'Session summary'
148
+ if ($ws.DryRun) {
149
+ Write-Kv 'Mode:' 'DRY-RUN - nothing was deleted'
150
+ Write-Kv 'Would free (estimate):' (Format-Bytes $ws.TotalEstimated)
151
+ } else {
152
+ Write-Kv 'Reclaimed:' (Format-Bytes $ws.TotalFreed)
153
+ }
154
+ $secs = [int][math]::Round(((Get-Date).Ticks - $ws.Report.startedTicks) / 10000000)
155
+ Write-Kv 'Duration:' (Format-Duration $secs)
156
+ $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
157
+ $skipped = @($ws.Report.steps | Where-Object { $_.status -notin 'ran', 'dry-run' }).Count
158
+ Write-Kv 'Sections run / skipped:' "$ran / $skipped"
159
+ Write-Kv 'Log:' $ws.LogFile
160
+ $rep = Save-Report
161
+ if ($rep) { Write-Kv 'Report (JSON):' $rep } elseif ($ws.NoReport) { Write-Kv 'Report:' '(disabled via --no-report)' }
162
+ Write-UiLine '' 'Gray'
163
+ Show-DriveTable
164
+ if ($ws.Refusals.Count -gt 0) {
165
+ Write-UiLine '' 'Gray'
166
+ Write-Warn 'refused in batch mode:'
167
+ foreach ($r in $ws.Refusals) { Write-Note $r }
168
+ }
169
+ if ($ws.Hints.Count -gt 0) {
170
+ Write-UiLine '' 'Gray'
171
+ Write-Info 'next steps:'
172
+ foreach ($h in ($ws.Hints | Sort-Object -Unique)) { Write-Note $h }
173
+ }
174
+ Write-UiLine '' 'Gray'
175
+ Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
176
+ if ($ws.JsonMode) { Write-JsonSummary }
177
+ }
178
+
179
+ function Write-JsonSummary {
180
+ <# .SYNOPSIS --json: the only line written to stdout. #>
181
+ $ws = $Script:WS
182
+ $doc = [ordered]@{
183
+ tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
184
+ elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
185
+ freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
186
+ sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
187
+ refusals = @($ws.Refusals); log_file = $ws.LogFile; report_file = $ws.ReportFile
188
+ }
189
+ [Console]::Out.WriteLine(($doc | ConvertTo-Json -Depth 5 -Compress))
190
+ }
@@ -0,0 +1,220 @@
1
+ # system_admin.ps1 - sections 12-16: Windows Update cache, Disk Cleanup engine, DISM, hibernation file, event logs.
2
+ # Every section here needs Administrator rights; the runner skips them (with the --elevate hint) when not elevated.
3
+
4
+ function Get-Targets12 {
5
+ $SR = $Script:P.SR; $PD = $Script:P.PD
6
+ return @(
7
+ (New-Target 12 'Windows Update download cache' "$SR\SoftwareDistribution\Download" -Mode clear -Note 'wuauserv and bits are stopped for the wipe and restarted')
8
+ (New-Target 12 'Delivery Optimization cache' 'Delete-DeliveryOptimizationCache -Force' -Kind cmd)
9
+ (New-Target 12 'Windows temp' "$SR\Temp" -Mode prune -Days $Script:WS.TempDays)
10
+ (New-Target 12 'LocalService temp' "$SR\ServiceProfiles\LocalService\AppData\Local\Temp" -Mode prune -Days $Script:WS.TempDays)
11
+ (New-Target 12 'NetworkService temp' "$SR\ServiceProfiles\NetworkService\AppData\Local\Temp" -Mode prune -Days $Script:WS.TempDays)
12
+ (New-Target 12 'CBS servicing logs' "$SR\Logs\CBS" -Mode prune -Days 30 -Note 'the live CBS.log is open and skipped')
13
+ (New-Target 12 'DISM logs' "$SR\Logs\DISM" -Mode prune -Days 30)
14
+ (New-Target 12 'Windows Update logs' "$SR\Logs\WindowsUpdate" -Mode prune -Days 30)
15
+ (New-Target 12 'Live kernel reports' "$SR\LiveKernelReports" -Mode clear-old -Days 7)
16
+ (New-Target 12 'Windows Error Reporting queue (system)' "$PD\Microsoft\Windows\WER\ReportQueue" -Mode clear)
17
+ (New-Target 12 'Windows Error Reporting archive (system)' "$PD\Microsoft\Windows\WER\ReportArchive" -Mode clear)
18
+ (New-Target 12 'Windows Error Reporting temp (system)' "$PD\Microsoft\Windows\WER\Temp" -Mode clear)
19
+ )
20
+ }
21
+
22
+ function Set-ServiceState {
23
+ <# .SYNOPSIS Stop or start a service through the dry-run-aware wrapper. Returns $true when it was running before a stop. #>
24
+ param([string] $Name, [string] $Action)
25
+ $svc = Get-Service -Name $Name -ErrorAction SilentlyContinue
26
+ if (-not $svc) { return $false }
27
+ if ($Action -eq 'stop') {
28
+ if ($svc.Status -ne 'Running') { return $false }
29
+ if ($Script:WS.DryRun) { Write-DryRun "would stop service $Name"; return $true }
30
+ try { Stop-Service -Name $Name -Force -ErrorAction Stop; Write-Note "stopped $Name" } catch { Write-Warn "could not stop $Name : $($_.Exception.Message)" }
31
+ return $true
32
+ }
33
+ if ($Script:WS.DryRun) { Write-DryRun "would start service $Name"; return $true }
34
+ try { Start-Service -Name $Name -ErrorAction Stop; Write-Note "started $Name" } catch { Write-Warn "could not start $Name : $($_.Exception.Message)" }
35
+ return $true
36
+ }
37
+
38
+ function Invoke-Section12 {
39
+ $ws = $Script:WS
40
+ Write-SectionIntro @(
41
+ 'Windows Update keeps every downloaded package after installing it; Delivery Optimization keeps peer-share',
42
+ 'copies; C:\Windows\Temp and the servicing logs grow forever. All of it is rebuilt on demand.'
43
+ )
44
+ if (-not $ws.IsAdmin) { Write-Warn 'needs Administrator - skipped'; return }
45
+ $targets = Get-Targets12
46
+ $null = Show-TargetSizes $targets
47
+ if (-not (Confirm-Section 'Clear Windows Update and system temp caches now?')) { Write-Info 'skipped'; return }
48
+ $wuWasRunning = Set-ServiceState -Name 'wuauserv' -Action 'stop'
49
+ $bitsWasRunning = Set-ServiceState -Name 'bits' -Action 'stop'
50
+ try {
51
+ $null = Invoke-TargetList $targets
52
+ } finally {
53
+ if ($wuWasRunning) { $null = Set-ServiceState -Name 'wuauserv' -Action 'start' }
54
+ if ($bitsWasRunning) { $null = Set-ServiceState -Name 'bits' -Action 'start' }
55
+ }
56
+ if (Get-Command Delete-DeliveryOptimizationCache -ErrorAction SilentlyContinue) {
57
+ if ($ws.DryRun) { Write-DryRun 'would run Delete-DeliveryOptimizationCache -Force' }
58
+ else {
59
+ try { Delete-DeliveryOptimizationCache -Force -ErrorAction Stop; Write-Ok 'Delivery Optimization cache cleared' } catch { Write-Warn "Delivery Optimization: $($_.Exception.Message)" }
60
+ }
61
+ }
62
+ }
63
+
64
+ # ---------------------------------------------------------------------------------------------
65
+ # Section 13 - Disk Cleanup engine (cleanmgr)
66
+ # ---------------------------------------------------------------------------------------------
67
+
68
+ # Handlers this tool enables. Deliberately absent: DownloadsFolder (your files), Recycle Bin (section 11 asks
69
+ # separately), User file versions (File History), Windows ESD installation files (needed for Reset this PC),
70
+ # Language Pack (removes installed languages).
71
+ $Script:WS_CLEANMGR_HANDLERS = @(
72
+ 'Active Setup Temp Folders', 'BranchCache', 'Content Indexer Cleaner', 'D3D Shader Cache', 'Delivery Optimization Files',
73
+ 'Device Driver Packages', 'Diagnostic Data Viewer database files', 'Downloaded Program Files', 'Internet Cache Files',
74
+ 'Offline Pages Files', 'Old ChkDsk Files', 'Previous Installations', 'RetailDemo Offline Content', 'Setup Log Files',
75
+ 'System error memory dump files', 'System error minidump files', 'Temporary Files', 'Temporary Setup Files',
76
+ 'Temporary Sync Files', 'Thumbnail Cache', 'Update Cleanup', 'Upgrade Discarded Files', 'Windows Defender',
77
+ 'Windows Error Reporting Files', 'Windows Upgrade Log Files'
78
+ )
79
+ $Script:WS_CLEANMGR_KEY = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches'
80
+ $Script:WS_CLEANMGR_FLAG = 'StateFlags0077'
81
+
82
+ function Get-Targets13 {
83
+ return @((New-Target 13 'cleanmgr /sagerun:77 with curated handlers' ($Script:WS_CLEANMGR_HANDLERS -join ', ') -Kind cmd -Note 'never Downloads, Recycle Bin, File History, ESD files or language packs'))
84
+ }
85
+
86
+ function Invoke-Section13 {
87
+ $ws = $Script:WS
88
+ Write-SectionIntro @(
89
+ "Windows' own Disk Cleanup engine, driven with a curated handler list: temporary files, Update Cleanup,",
90
+ 'thumbnail cache, memory dumps, setup and upgrade logs, previous Windows installations. It never touches',
91
+ 'your Downloads folder, the Recycle Bin, File History or the files Reset-this-PC needs.'
92
+ )
93
+ if (-not $ws.IsAdmin) { Write-Warn 'needs Administrator - skipped'; return }
94
+ if (-not (Test-CommandPresent 'cleanmgr.exe')) { Write-Warn 'cleanmgr.exe not found on this edition of Windows - skipped'; return }
95
+ $present = @()
96
+ foreach ($h in $Script:WS_CLEANMGR_HANDLERS) { if (Test-Path -LiteralPath (Join-Path $Script:WS_CLEANMGR_KEY $h)) { $present += $h } }
97
+ Write-Info "handlers available on this machine: $($present.Count) of $($Script:WS_CLEANMGR_HANDLERS.Count)"
98
+ foreach ($h in $present) { Write-Note $h }
99
+ if ($ws.DryRun) { Write-DryRun 'would run cleanmgr.exe /sagerun:77 with those handlers (cleanmgr cannot preview sizes)'; return }
100
+ if (-not (Confirm-Section 'Run Disk Cleanup with these handlers now? (a progress window may appear; it can take several minutes)')) { Write-Info 'skipped'; return }
101
+ $before = Get-SystemDriveFree
102
+ try {
103
+ foreach ($h in $present) { Set-ItemProperty -LiteralPath (Join-Path $Script:WS_CLEANMGR_KEY $h) -Name $Script:WS_CLEANMGR_FLAG -Value 2 -Type DWord -ErrorAction Stop }
104
+ $r = Invoke-External -FilePath 'cleanmgr.exe' -ArgumentList @('/sagerun:77') -Destructive -Quiet -Label 'cleanmgr /sagerun:77'
105
+ if ($r.ExitCode -ne 0) { Write-Warn "cleanmgr returned $($r.ExitCode)" }
106
+ } catch {
107
+ Write-Err "could not configure Disk Cleanup: $($_.Exception.Message)"
108
+ } finally {
109
+ foreach ($h in $present) { Remove-ItemProperty -LiteralPath (Join-Path $Script:WS_CLEANMGR_KEY $h) -Name $Script:WS_CLEANMGR_FLAG -ErrorAction SilentlyContinue }
110
+ }
111
+ $gain = [math]::Max([long]0, (Get-SystemDriveFree) - $before)
112
+ Add-Freed $gain
113
+ Write-Ok ("Disk Cleanup finished - system drive gained " + (Format-Bytes $gain))
114
+ }
115
+
116
+ # ---------------------------------------------------------------------------------------------
117
+ # Section 14 - DISM component store
118
+ # ---------------------------------------------------------------------------------------------
119
+
120
+ function Get-Targets14 {
121
+ $note = 'superseded component versions in WinSxS'
122
+ if ($Script:WS.ResetBase) { $note += '; --reset-base also removes the ability to uninstall installed updates' }
123
+ return @((New-Target 14 'Dism /Online /Cleanup-Image /StartComponentCleanup' "$($Script:P.SR)\WinSxS" -Kind cmd -Note $note))
124
+ }
125
+
126
+ function Invoke-Section14 {
127
+ $ws = $Script:WS
128
+ Write-SectionIntro @(
129
+ 'The component store (WinSxS) keeps superseded versions of Windows components. DISM removes the ones no',
130
+ 'installed update needs. It is slow (5-20 minutes) and safe; /ResetBase (opt-in) also drops the ability to',
131
+ 'uninstall currently installed updates.'
132
+ )
133
+ if (-not $ws.IsAdmin) { Write-Warn 'needs Administrator - skipped'; return }
134
+ if (-not (Test-CommandPresent 'Dism.exe')) { Write-Warn 'Dism.exe not found - skipped'; return }
135
+ Write-Info 'analyzing the component store (read-only, about a minute)...'
136
+ $a = Invoke-External -FilePath 'Dism.exe' -ArgumentList @('/Online', '/Cleanup-Image', '/AnalyzeComponentStore') -Quiet -Label 'dism analyze'
137
+ foreach ($l in $a.Output) { if ($l -match 'Size|Cleanup|Reclaimable|Date' ) { Write-Note ($l.Trim()) } }
138
+ $dismArgs = @('/Online', '/Cleanup-Image', '/StartComponentCleanup')
139
+ if ($ws.ResetBase) { $dismArgs += '/ResetBase'; Write-Warn '/ResetBase requested: installed updates can no longer be uninstalled afterwards' }
140
+ if (-not (Confirm-Section 'Run the component store cleanup now?')) { Write-Info 'skipped'; return }
141
+ $before = Get-SystemDriveFree
142
+ $r = Invoke-External -FilePath 'Dism.exe' -ArgumentList $dismArgs -Destructive -Quiet -Label 'dism StartComponentCleanup'
143
+ if ($r.Ran) {
144
+ $gain = [math]::Max([long]0, (Get-SystemDriveFree) - $before)
145
+ Add-Freed $gain
146
+ if ($r.ExitCode -eq 0) { Write-Ok ("component store cleanup finished - system drive gained " + (Format-Bytes $gain)) } else { Write-Warn "DISM returned $($r.ExitCode) - see the log" }
147
+ }
148
+ }
149
+
150
+ # ---------------------------------------------------------------------------------------------
151
+ # Section 15 - hibernation file
152
+ # ---------------------------------------------------------------------------------------------
153
+
154
+ function Get-Targets15 {
155
+ return @((New-Target 15 'hiberfil.sys (powercfg /hibernate off | /type reduced)' (Join-Path $Script:P.SD '\hiberfil.sys') -Kind cmd -Note 'off frees the whole file; reduced keeps Fast Startup at roughly 40% of RAM'))
156
+ }
157
+
158
+ function Invoke-Section15 {
159
+ $ws = $Script:WS
160
+ $h = Get-HiberfilInfo
161
+ Write-SectionIntro @(
162
+ 'hiberfil.sys holds a copy of RAM for Hibernate and Fast Startup, so it is about 40% of your RAM - permanently.',
163
+ ' off removes it. Sleep still works; Hibernate, Fast Startup and hibernate-on-critical-battery do not.',
164
+ ' reduced keeps Fast Startup only, at a smaller file (Windows decides the size, usually about half).',
165
+ 'Running speed is unaffected either way; this is purely disk space.'
166
+ )
167
+ if ($h.Bytes -gt 0) { Write-Kv 'hiberfil.sys now:' ("{0} (hibernation {1})" -f (Format-Bytes $h.Bytes), $h.State) } else { Write-Info 'hiberfil.sys is absent - hibernation is already off'; return }
168
+ if (-not $ws.IsAdmin) { Write-Warn 'needs Administrator - skipped'; return }
169
+ $choice = $ws.Hiberfil
170
+ if (-not $choice) {
171
+ if (-not $ws.Interactive) { Write-Warn 'pass --hiberfil off|reduced|keep to run this section unattended'; return }
172
+ $choice = Read-Choice -Prompt ' [o] turn hibernation off [r] reduced (keep Fast Startup) [k] keep as is > ' -Default 'k'
173
+ switch ($choice) { 'o' { $choice = 'off' } 'r' { $choice = 'reduced' } default { $choice = 'keep' } }
174
+ }
175
+ if ($choice -eq 'keep') { Write-Info 'left unchanged'; return }
176
+ $pcArgs = @('/hibernate', 'off')
177
+ if ($choice -eq 'reduced') { $pcArgs = @('/hibernate', '/type', 'reduced') }
178
+ if ($ws.DryRun) { Write-DryRun ("would run powercfg $($pcArgs -join ' ') (frees up to " + (Format-Bytes $h.Bytes) + ')'); Add-Freed $h.Bytes; return }
179
+ if (-not (Confirm-Ui -Prompt "Run powercfg $($pcArgs -join ' ') now?" -Default 'n')) { Write-Info 'skipped'; return }
180
+ $r = Invoke-External -FilePath 'powercfg.exe' -ArgumentList $pcArgs -Destructive -Quiet -Label "powercfg $($pcArgs -join ' ')"
181
+ Start-Sleep -Seconds 2
182
+ $after = Get-HiberfilInfo
183
+ $gain = [math]::Max([long]0, $h.Bytes - $after.Bytes)
184
+ Add-Freed $gain
185
+ if ($r.ExitCode -eq 0) { Write-Ok ("hibernation set to '$choice' - hiberfil.sys " + (Format-Bytes $h.Bytes) + ' -> ' + (Format-Bytes $after.Bytes) + ' (freed ' + (Format-Bytes $gain) + ')') } else { Write-Warn "powercfg returned $($r.ExitCode)" }
186
+ }
187
+
188
+ # ---------------------------------------------------------------------------------------------
189
+ # Section 16 - event logs
190
+ # ---------------------------------------------------------------------------------------------
191
+
192
+ function Get-Targets16 {
193
+ return @((New-Target 16 'Windows Event Logs (wevtutil cl for every log)' "$($Script:P.SR)\System32\winevt\Logs" -Kind cmd -Note 'PERMANENT - troubleshooting history is lost'))
194
+ }
195
+
196
+ function Invoke-Section16 {
197
+ $ws = $Script:WS
198
+ $logDir = Join-Path $Script:P.SR 'System32\winevt\Logs'
199
+ Write-SectionIntro @(
200
+ 'Clears every Windows Event Log (Application, System, Security and the hundreds of per-component logs).',
201
+ 'This is permanent and removes the history a technician would use to diagnose a problem. Only do this on',
202
+ 'a machine that is behaving normally.'
203
+ )
204
+ if (-not $ws.IsAdmin) { Write-Warn 'needs Administrator - skipped'; return }
205
+ $before = Get-DirectoryBytes $logDir
206
+ Write-Kv 'event log store:' (Format-Bytes $before)
207
+ if ($ws.DryRun) { Write-DryRun ("would clear every event log (about " + (Format-Bytes $before) + ')'); Add-Freed $before; return }
208
+ if (-not (Confirm-Ui -Prompt 'Permanently clear ALL Windows Event Logs?' -Default 'n')) { Write-Info 'skipped'; return }
209
+ $list = Invoke-External -FilePath 'wevtutil.exe' -ArgumentList @('el') -Quiet -Label 'wevtutil el'
210
+ $ok = 0; $failed = 0
211
+ foreach ($name in $list.Output) {
212
+ $n = ([string]$name).Trim()
213
+ if (-not $n) { continue }
214
+ $r = Invoke-External -FilePath 'wevtutil.exe' -ArgumentList @('cl', $n) -Destructive -Quiet -Label "wevtutil cl $n"
215
+ if ($r.ExitCode -eq 0) { $ok++ } else { $failed++ }
216
+ }
217
+ $gain = [math]::Max([long]0, $before - (Get-DirectoryBytes $logDir))
218
+ Add-Freed $gain
219
+ Write-Ok ("cleared $ok logs ($failed could not be cleared - analytic/debug channels), freed " + (Format-Bytes $gain))
220
+ }
@@ -0,0 +1,24 @@
1
+ # test_runners.ps1 - section 3: downloaded test browsers (Cypress, Playwright, Puppeteer). Keep the newest of each.
2
+
3
+ $Script:WS_GROUP_BY_FAMILY = { param($name) ($name -replace '[-_]?v?\d[\d.]*$', '') }
4
+
5
+ function Get-Targets03 {
6
+ $P = $Script:P
7
+ return @(
8
+ (New-Target 3 'Cypress binary versions' "$($P.L)\Cypress\Cache" -Mode units -Dev $true -KeepNewest $true -Note 'one folder per Cypress version; newest kept')
9
+ (New-Target 3 'Playwright browsers' "$($P.L)\ms-playwright" -Mode units -Dev $true -KeepNewest $true -GroupBy $Script:WS_GROUP_BY_FAMILY -ExcludeNames @('.links', 'daemon') -Note 'newest build of each browser kept')
10
+ (New-Target 3 'Playwright-Go browsers' "$($P.L)\ms-playwright-go" -Mode units -Dev $true -KeepNewest $true -GroupBy $Script:WS_GROUP_BY_FAMILY)
11
+ (New-Target 3 'Puppeteer browsers' "$($P.U)\.cache\puppeteer\*" -Kind glob -Mode units -Dev $true -KeepNewest $true -Note 'per browser family: newest build kept')
12
+ )
13
+ }
14
+
15
+ function Invoke-Section03 {
16
+ Write-SectionIntro @(
17
+ 'Test runners download whole browsers per version. The newest build of each browser is always kept; older',
18
+ 'builds go once idle for the window. A missing build is re-downloaded by the next install or "playwright install".'
19
+ ) -Dev $true
20
+ $targets = Get-Targets03
21
+ $null = Show-TargetSizes $targets
22
+ if (-not (Confirm-Section 'Prune superseded test-runner browsers now?')) { Write-Info 'skipped'; return }
23
+ $null = Invoke-TargetList $targets
24
+ }