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
package/lib/ui.ps1 CHANGED
@@ -1,275 +1,275 @@
1
- # ui.ps1 - console output, glyphs, colours and prompts. ASCII only; glyphs come from [char] codes.
2
- # Every human-readable line goes through Write-UiLine so it is also logged and, in --json mode,
3
- # routed to stderr (stdout then carries only the JSON summary).
4
-
5
- function Initialize-Ui {
6
- <# .SYNOPSIS Decide colour + glyph set from flags, environment and redirection state. #>
7
- $ws = $Script:WS
8
- $outRedirected = [Console]::IsOutputRedirected
9
- $inRedirected = [Console]::IsInputRedirected
10
- $noColorEnv = (-not [string]::IsNullOrEmpty($env:NO_COLOR)) -or ($env:WINDOWSWEEP_NO_COLOR -eq '1')
11
- $ws.Color = (-not $ws.NoColor) -and (-not $noColorEnv) -and (-not $outRedirected) -and (-not $ws.JsonMode)
12
- $ws.Interactive = (-not $inRedirected) -and (-not $ws.JsonMode)
13
- $useUnicode = (-not $ws.Ascii) -and (-not $outRedirected) -and ($env:WINDOWSWEEP_ASCII -ne '1')
14
- if ($useUnicode) {
15
- $ws.Glyph = @{
16
- ok = [string][char]0x2713; err = [string][char]0x2717; info = [string][char]0x2139; warn = '!'
17
- bullet = [string][char]0x00B7; arrow = [string][char]0x2192; hline = [string][char]0x2500
18
- dline = [string][char]0x2550; tl = [string][char]0x2554; tr = [string][char]0x2557
19
- bl = [string][char]0x255A; br = [string][char]0x255D; vbar = [string][char]0x2551
20
- }
21
- try {
22
- $ws.PrevOutputEncoding = [Console]::OutputEncoding
23
- [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
24
- } catch { $ws.PrevOutputEncoding = $null }
25
- } else {
26
- $ws.Glyph = @{ ok = '+'; err = 'x'; info = 'i'; warn = '!'; bullet = '*'; arrow = '->'; hline = '-'; dline = '='; tl = '+'; tr = '+'; bl = '+'; br = '+'; vbar = '|' }
27
- }
28
- }
29
-
30
- function Restore-Ui {
31
- <# .SYNOPSIS Put the console encoding back the way we found it. #>
32
- $ws = $Script:WS
33
- if ($ws.PrevOutputEncoding) { try { [Console]::OutputEncoding = $ws.PrevOutputEncoding } catch { $null = $_ } }
34
- }
35
-
36
- function Write-UiLine {
37
- <# .SYNOPSIS Print one line (coloured when allowed) and append it to the log. #>
38
- param([string] $Text = '', [string] $Color = 'Gray', [switch] $NoLog, [switch] $NoNewline)
39
- $ws = $Script:WS
40
- if (-not $NoLog) { Write-LogLine $Text }
41
- if ($ws.Mute) { return }
42
- if ($ws.JsonMode) { [Console]::Error.WriteLine($Text); return }
43
- if ($ws.Color) {
44
- if ($NoNewline) { Write-Host $Text -ForegroundColor $Color -NoNewline } else { Write-Host $Text -ForegroundColor $Color }
45
- } else {
46
- if ($NoNewline) { Write-Host $Text -NoNewline } else { Write-Host $Text }
47
- }
48
- }
49
-
50
- function Write-Info {
51
- param([string] $Text)
52
- if ($Script:WS.Quiet) { Write-LogLine "INFO $Text"; return }
53
- Write-UiLine ("$($Script:WS.Glyph.info) $Text") 'Cyan'
54
- }
55
-
56
- function Write-Ok {
57
- param([string] $Text)
58
- Write-UiLine ("$($Script:WS.Glyph.ok) $Text") 'Green'
59
- }
60
-
61
- function Write-Warn {
62
- param([string] $Text)
63
- Write-UiLine ("$($Script:WS.Glyph.warn) $Text") 'Yellow'
64
- }
65
-
66
- function Write-Err {
67
- param([string] $Text)
68
- Write-UiLine ("$($Script:WS.Glyph.err) $Text") 'Red'
69
- }
70
-
71
- function Write-Note {
72
- param([string] $Text)
73
- if ($Script:WS.Quiet) { Write-LogLine "NOTE $Text"; return }
74
- Write-UiLine (" $Text") 'DarkGray'
75
- }
76
-
77
- function Write-Plain {
78
- param([string] $Text)
79
- Write-UiLine $Text 'Gray'
80
- }
81
-
82
- function Write-DryRun {
83
- param([string] $Text)
84
- Write-UiLine (" [dry-run] $Text") 'Magenta'
85
- }
86
-
87
- function Write-Section {
88
- <# .SYNOPSIS A dashed section heading. #>
89
- param([string] $Title)
90
- $h = $Script:WS.Glyph.hline
91
- Write-UiLine '' 'Gray'
92
- Write-UiLine ("$h$h $Title $h$h") 'Blue'
93
- }
94
-
95
- function Write-Separator {
96
- Write-UiLine ([string]::new([char]($Script:WS.Glyph.hline), 78)) 'DarkGray'
97
- }
98
-
99
- function Write-Box {
100
- <# .SYNOPSIS Double-lined title box with an optional dim subtitle. #>
101
- param([string] $Title, [string] $Subtitle = '')
102
- $line = [string]::new([char]($Script:WS.Glyph.dline), 78)
103
- Write-UiLine '' 'Gray'
104
- Write-UiLine $line 'Blue'
105
- Write-UiLine (" $Title") 'Blue'
106
- if ($Subtitle) { Write-UiLine (" $Subtitle") 'DarkGray' }
107
- Write-UiLine $line 'Blue'
108
- }
109
-
110
- function Write-Step {
111
- <# .SYNOPSIS Walkthrough step header: [ STEP n/N ] Title #>
112
- param([int] $N, [int] $Total, [string] $Title)
113
- Write-UiLine '' 'Gray'
114
- Write-Separator
115
- Write-UiLine (" [ STEP $N/$Total ] $Title") 'Cyan'
116
- Write-Separator
117
- }
118
-
119
- function Write-Kv {
120
- param([string] $Key, [string] $Value)
121
- Write-UiLine (" {0,-24} {1}" -f $Key, $Value) 'Gray'
122
- }
123
-
124
- function Write-Banner {
125
- $g = $Script:WS.Glyph
126
- $inner = " $Script:WS_NAME v$(Get-ToolVersion) - safe, developer-aware Windows cleanup "
127
- $bar = [string]::new([char]($g.dline), $inner.Length)
128
- Write-UiLine ("$($g.tl)$bar$($g.tr)") 'Blue'
129
- Write-UiLine ("$($g.vbar)$inner$($g.vbar)") 'Blue'
130
- Write-UiLine ("$($g.bl)$bar$($g.br)") 'Blue'
131
- }
132
-
133
- function Format-Bytes {
134
- <# .SYNOPSIS Human-readable size (B, KB, MB, GB, TB). #>
135
- param([double] $Bytes)
136
- if ($Bytes -lt 0) { $Bytes = 0 }
137
- $units = @('B', 'KB', 'MB', 'GB', 'TB')
138
- $i = 0
139
- while ($Bytes -ge 1024 -and $i -lt ($units.Length - 1)) { $Bytes = $Bytes / 1024; $i++ }
140
- if ($i -eq 0) { return ("{0:N0} {1}" -f $Bytes, $units[$i]) }
141
- return ("{0:N1} {1}" -f $Bytes, $units[$i])
142
- }
143
-
144
- function Format-Duration {
145
- param([double] $Seconds)
146
- $m = [math]::Floor($Seconds / 60); $s = [math]::Floor($Seconds % 60)
147
- return ("{0}m {1:00}s" -f $m, $s)
148
- }
149
-
150
- function Read-Line {
151
- <# .SYNOPSIS Read one line from the console, or $null when not interactive / at EOF. #>
152
- param([string] $Prompt = '')
153
- if (-not $Script:WS.Interactive) { return $null }
154
- if ($Prompt) { Write-UiLine $Prompt 'White' -NoNewline -NoLog }
155
- try { $line = [Console]::In.ReadLine() } catch { return $null }
156
- if ($null -eq $line) { return $null }
157
- Write-LogLine ("> $line")
158
- return $line
159
- }
160
-
161
- function Confirm-Ui {
162
- <# .SYNOPSIS y/N prompt. --yes auto-confirms (unless -NoAutoYes); non-interactive runs answer no.
163
- -ScriptedOk: the prompt that follows an explicit --select / --select-file choice. The selection IS the
164
- person's decision, so it answers this one question - and only when the preceding prompt really was
165
- answered from a script (LastSelectionScripted), never merely because the flag was passed. #>
166
- param([string] $Prompt = 'Proceed?', [string] $Default = 'n', [switch] $NoAutoYes, [switch] $ScriptedOk)
167
- $ws = $Script:WS
168
- if ($ScriptedOk -and $ws.LastSelectionScripted) { Write-Warn "[scripted selection] $Prompt - yes (--select / --select-file)"; return $true }
169
- if ($ws.Yes -and -not $NoAutoYes) { Write-UiLine (" [auto-yes] $Prompt") 'DarkGray'; return $true }
170
- if (-not $ws.Interactive) {
171
- Write-Note "non-interactive session: '$Prompt' answered no (pass --yes to confirm in batch)"
172
- return $false
173
- }
174
- $hint = '[y/N]'
175
- if ($Default -eq 'y') { $hint = '[Y/n]' }
176
- $reply = Read-Line ("? $Prompt $hint ")
177
- if ($null -eq $reply) { return $false }
178
- $reply = $reply.Trim()
179
- if ($reply -eq '') { $reply = $Default }
180
- return ($reply -match '^(y|yes)$')
181
- }
182
-
183
- function Confirm-Typed {
184
- <# .SYNOPSIS Critical confirmation: the user must type the literal word. Never auto-answered. #>
185
- param([string] $Prompt, [string] $Word = 'yes')
186
- if (-not $Script:WS.Interactive) { Write-Note "non-interactive session: '$Prompt' requires a typed '$Word'; skipped"; return $false }
187
- Write-Warn $Prompt
188
- $reply = Read-Line (" Type '$Word' to proceed: ")
189
- if ($null -eq $reply) { return $false }
190
- return ($reply.Trim() -eq $Word)
191
- }
192
-
193
- function Read-MultiSelect {
194
- <# .SYNOPSIS Parse "1,3,5-7" / all / none into a sorted unique list of 1-based indexes.
195
- Order of answers: a --select-file path list matched against -Candidates, then the next queued --select
196
- list, then --yes (only for callers that do not pass -NoAutoYes), then the console, then nothing.
197
- --yes NEVER selects for the personal and project pickers: they pass -NoAutoYes, so under --yes they
198
- still ask when a console is present and select nothing when it is not. #>
199
- param([int] $Total, [switch] $NoAutoYes, [string[]] $Candidates = @())
200
- $ws = $Script:WS
201
- $ws.LastSelectionScripted = $false
202
- if ($Total -le 0) { return @() }
203
- if ($ws.SelectPaths.Count -gt 0 -and $Candidates.Count -gt 0) {
204
- $picked = @(Resolve-SelectedPaths -Candidates $Candidates)
205
- $ws.LastSelectionScripted = $true
206
- Write-Info ("--select-file matched $($picked.Count) of $Total candidate(s)")
207
- return $picked
208
- }
209
- if ($ws.SelectQueue.Count -gt 0) {
210
- $spec = [string]$ws.SelectQueue[0]
211
- $ws.SelectQueue = @($ws.SelectQueue | Select-Object -Skip 1)
212
- $picked = @(ConvertTo-IndexList -Text $spec -Total $Total)
213
- $ws.LastSelectionScripted = $true
214
- Write-Info ("--select '$spec' -> $($picked.Count) of $Total item(s)")
215
- return $picked
216
- }
217
- if ($ws.Yes -and -not $NoAutoYes) { return @(1..$Total) }
218
- if (-not $ws.Interactive) { Write-Note 'non-interactive session: nothing selected (a person has to pick these)'; return @() }
219
- Write-UiLine " Selection: 1,3,5-10 | all | none (Enter = none)" 'DarkGray' -NoLog
220
- $reply = Read-Line (" Select items (1..$Total): ")
221
- return (ConvertTo-IndexList -Text $reply -Total $Total)
222
- }
223
-
224
- function Resolve-SelectedPaths {
225
- <# .SYNOPSIS Match the --select-file lines against this prompt's candidate paths, case-insensitively.
226
- Returns 1-based indexes into $Candidates. A line matching nothing here is reported once and skipped:
227
- the same file is offered to every prompt, so an unmatched line is normal and never fatal. #>
228
- param([string[]] $Candidates)
229
- $set = New-Object System.Collections.Generic.SortedSet[int]
230
- $matched = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
231
- for ($i = 0; $i -lt $Candidates.Count; $i++) {
232
- foreach ($want in $Script:WS.SelectPaths) {
233
- if ([string]$Candidates[$i] -eq $want) { $null = $set.Add($i + 1); $null = $matched.Add($want) }
234
- }
235
- }
236
- foreach ($want in $Script:WS.SelectPaths) {
237
- if (-not $matched.Contains($want)) { Write-Note "--select-file: no candidate here matches $want" }
238
- }
239
- return @($set)
240
- }
241
-
242
- function ConvertTo-IndexList {
243
- <# .SYNOPSIS Pure parser behind Read-MultiSelect (kept separate so the self-test can exercise it). #>
244
- param([string] $Text, [int] $Total)
245
- if ([string]::IsNullOrWhiteSpace($Text)) { return @() }
246
- $t = $Text.Trim().ToLowerInvariant()
247
- if ($t -in @('n', 'none')) { return @() }
248
- if ($t -in @('a', 'all')) { return @(1..$Total) }
249
- $set = New-Object System.Collections.Generic.SortedSet[int]
250
- foreach ($part in ($t -split ',')) {
251
- $part = $part.Trim()
252
- if ($part -match '^(\d+)-(\d+)$') {
253
- $a = [int]$Matches[1]; $b = [int]$Matches[2]
254
- if ($a -gt $b) { $tmp = $a; $a = $b; $b = $tmp }
255
- for ($i = $a; $i -le $b; $i++) { if ($i -ge 1 -and $i -le $Total) { $null = $set.Add($i) } }
256
- } elseif ($part -match '^\d+$') {
257
- $i = [int]$part
258
- if ($i -ge 1 -and $i -le $Total) { $null = $set.Add($i) }
259
- }
260
- }
261
- return @($set)
262
- }
263
-
264
- function Read-Choice {
265
- <# .SYNOPSIS One-key choice; returns the lower-cased key or the default. #>
266
- param([string] $Prompt, [string] $Default = '')
267
- $reply = Read-Line $Prompt
268
- if ($null -eq $reply -or $reply.Trim() -eq '') { return $Default }
269
- return $reply.Trim().ToLowerInvariant()
270
- }
271
-
272
- function Wait-Enter {
273
- if (-not $Script:WS.Interactive) { return }
274
- $null = Read-Line " - press Enter to continue - "
275
- }
1
+ # ui.ps1 - console output, glyphs, colours and prompts. ASCII only; glyphs come from [char] codes.
2
+ # Every human-readable line goes through Write-UiLine so it is also logged and, in --json mode,
3
+ # routed to stderr (stdout then carries only the JSON summary).
4
+
5
+ function Initialize-Ui {
6
+ <# .SYNOPSIS Decide colour + glyph set from flags, environment and redirection state. #>
7
+ $ws = $Script:WS
8
+ $outRedirected = [Console]::IsOutputRedirected
9
+ $inRedirected = [Console]::IsInputRedirected
10
+ $noColorEnv = (-not [string]::IsNullOrEmpty($env:NO_COLOR)) -or ($env:WINDOWSWEEP_NO_COLOR -eq '1')
11
+ $ws.Color = (-not $ws.NoColor) -and (-not $noColorEnv) -and (-not $outRedirected) -and (-not $ws.JsonMode)
12
+ $ws.Interactive = (-not $inRedirected) -and (-not $ws.JsonMode)
13
+ $useUnicode = (-not $ws.Ascii) -and (-not $outRedirected) -and ($env:WINDOWSWEEP_ASCII -ne '1')
14
+ if ($useUnicode) {
15
+ $ws.Glyph = @{
16
+ ok = [string][char]0x2713; err = [string][char]0x2717; info = [string][char]0x2139; warn = '!'
17
+ bullet = [string][char]0x00B7; arrow = [string][char]0x2192; hline = [string][char]0x2500
18
+ dline = [string][char]0x2550; tl = [string][char]0x2554; tr = [string][char]0x2557
19
+ bl = [string][char]0x255A; br = [string][char]0x255D; vbar = [string][char]0x2551
20
+ }
21
+ try {
22
+ $ws.PrevOutputEncoding = [Console]::OutputEncoding
23
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
24
+ } catch { $ws.PrevOutputEncoding = $null }
25
+ } else {
26
+ $ws.Glyph = @{ ok = '+'; err = 'x'; info = 'i'; warn = '!'; bullet = '*'; arrow = '->'; hline = '-'; dline = '='; tl = '+'; tr = '+'; bl = '+'; br = '+'; vbar = '|' }
27
+ }
28
+ }
29
+
30
+ function Restore-Ui {
31
+ <# .SYNOPSIS Put the console encoding back the way we found it. #>
32
+ $ws = $Script:WS
33
+ if ($ws.PrevOutputEncoding) { try { [Console]::OutputEncoding = $ws.PrevOutputEncoding } catch { $null = $_ } }
34
+ }
35
+
36
+ function Write-UiLine {
37
+ <# .SYNOPSIS Print one line (coloured when allowed) and append it to the log. #>
38
+ param([string] $Text = '', [string] $Color = 'Gray', [switch] $NoLog, [switch] $NoNewline)
39
+ $ws = $Script:WS
40
+ if (-not $NoLog) { Write-LogLine $Text }
41
+ if ($ws.Mute) { return }
42
+ if ($ws.JsonMode) { [Console]::Error.WriteLine($Text); return }
43
+ if ($ws.Color) {
44
+ if ($NoNewline) { Write-Host $Text -ForegroundColor $Color -NoNewline } else { Write-Host $Text -ForegroundColor $Color }
45
+ } else {
46
+ if ($NoNewline) { Write-Host $Text -NoNewline } else { Write-Host $Text }
47
+ }
48
+ }
49
+
50
+ function Write-Info {
51
+ param([string] $Text)
52
+ if ($Script:WS.Quiet) { Write-LogLine "INFO $Text"; return }
53
+ Write-UiLine ("$($Script:WS.Glyph.info) $Text") 'Cyan'
54
+ }
55
+
56
+ function Write-Ok {
57
+ param([string] $Text)
58
+ Write-UiLine ("$($Script:WS.Glyph.ok) $Text") 'Green'
59
+ }
60
+
61
+ function Write-Warn {
62
+ param([string] $Text)
63
+ Write-UiLine ("$($Script:WS.Glyph.warn) $Text") 'Yellow'
64
+ }
65
+
66
+ function Write-Err {
67
+ param([string] $Text)
68
+ Write-UiLine ("$($Script:WS.Glyph.err) $Text") 'Red'
69
+ }
70
+
71
+ function Write-Note {
72
+ param([string] $Text)
73
+ if ($Script:WS.Quiet) { Write-LogLine "NOTE $Text"; return }
74
+ Write-UiLine (" $Text") 'DarkGray'
75
+ }
76
+
77
+ function Write-Plain {
78
+ param([string] $Text)
79
+ Write-UiLine $Text 'Gray'
80
+ }
81
+
82
+ function Write-DryRun {
83
+ param([string] $Text)
84
+ Write-UiLine (" [dry-run] $Text") 'Magenta'
85
+ }
86
+
87
+ function Write-Section {
88
+ <# .SYNOPSIS A dashed section heading. #>
89
+ param([string] $Title)
90
+ $h = $Script:WS.Glyph.hline
91
+ Write-UiLine '' 'Gray'
92
+ Write-UiLine ("$h$h $Title $h$h") 'Blue'
93
+ }
94
+
95
+ function Write-Separator {
96
+ Write-UiLine ([string]::new([char]($Script:WS.Glyph.hline), 78)) 'DarkGray'
97
+ }
98
+
99
+ function Write-Box {
100
+ <# .SYNOPSIS Double-lined title box with an optional dim subtitle. #>
101
+ param([string] $Title, [string] $Subtitle = '')
102
+ $line = [string]::new([char]($Script:WS.Glyph.dline), 78)
103
+ Write-UiLine '' 'Gray'
104
+ Write-UiLine $line 'Blue'
105
+ Write-UiLine (" $Title") 'Blue'
106
+ if ($Subtitle) { Write-UiLine (" $Subtitle") 'DarkGray' }
107
+ Write-UiLine $line 'Blue'
108
+ }
109
+
110
+ function Write-Step {
111
+ <# .SYNOPSIS Walkthrough step header: [ STEP n/N ] Title #>
112
+ param([int] $N, [int] $Total, [string] $Title)
113
+ Write-UiLine '' 'Gray'
114
+ Write-Separator
115
+ Write-UiLine (" [ STEP $N/$Total ] $Title") 'Cyan'
116
+ Write-Separator
117
+ }
118
+
119
+ function Write-Kv {
120
+ param([string] $Key, [string] $Value)
121
+ Write-UiLine (" {0,-24} {1}" -f $Key, $Value) 'Gray'
122
+ }
123
+
124
+ function Write-Banner {
125
+ $g = $Script:WS.Glyph
126
+ $inner = " $Script:WS_NAME v$(Get-ToolVersion) - names every path before it touches one "
127
+ $bar = [string]::new([char]($g.dline), $inner.Length)
128
+ Write-UiLine ("$($g.tl)$bar$($g.tr)") 'Blue'
129
+ Write-UiLine ("$($g.vbar)$inner$($g.vbar)") 'Blue'
130
+ Write-UiLine ("$($g.bl)$bar$($g.br)") 'Blue'
131
+ }
132
+
133
+ function Format-Bytes {
134
+ <# .SYNOPSIS Human-readable size (B, KB, MB, GB, TB). #>
135
+ param([double] $Bytes)
136
+ if ($Bytes -lt 0) { $Bytes = 0 }
137
+ $units = @('B', 'KB', 'MB', 'GB', 'TB')
138
+ $i = 0
139
+ while ($Bytes -ge 1024 -and $i -lt ($units.Length - 1)) { $Bytes = $Bytes / 1024; $i++ }
140
+ if ($i -eq 0) { return ("{0:N0} {1}" -f $Bytes, $units[$i]) }
141
+ return ("{0:N1} {1}" -f $Bytes, $units[$i])
142
+ }
143
+
144
+ function Format-Duration {
145
+ param([double] $Seconds)
146
+ $m = [math]::Floor($Seconds / 60); $s = [math]::Floor($Seconds % 60)
147
+ return ("{0}m {1:00}s" -f $m, $s)
148
+ }
149
+
150
+ function Read-Line {
151
+ <# .SYNOPSIS Read one line from the console, or $null when not interactive / at EOF. #>
152
+ param([string] $Prompt = '')
153
+ if (-not $Script:WS.Interactive) { return $null }
154
+ if ($Prompt) { Write-UiLine $Prompt 'White' -NoNewline -NoLog }
155
+ try { $line = [Console]::In.ReadLine() } catch { return $null }
156
+ if ($null -eq $line) { return $null }
157
+ Write-LogLine ("> $line")
158
+ return $line
159
+ }
160
+
161
+ function Confirm-Ui {
162
+ <# .SYNOPSIS y/N prompt. --yes auto-confirms (unless -NoAutoYes); non-interactive runs answer no.
163
+ -ScriptedOk: the prompt that follows an explicit --select / --select-file choice. The selection IS the
164
+ person's decision, so it answers this one question - and only when the preceding prompt really was
165
+ answered from a script (LastSelectionScripted), never merely because the flag was passed. #>
166
+ param([string] $Prompt = 'Proceed?', [string] $Default = 'n', [switch] $NoAutoYes, [switch] $ScriptedOk)
167
+ $ws = $Script:WS
168
+ if ($ScriptedOk -and $ws.LastSelectionScripted) { Write-Warn "[scripted selection] $Prompt - yes (--select / --select-file)"; return $true }
169
+ if ($ws.Yes -and -not $NoAutoYes) { Write-UiLine (" [auto-yes] $Prompt") 'DarkGray'; return $true }
170
+ if (-not $ws.Interactive) {
171
+ Write-Note "non-interactive session: '$Prompt' answered no (pass --yes to confirm in batch)"
172
+ return $false
173
+ }
174
+ $hint = '[y/N]'
175
+ if ($Default -eq 'y') { $hint = '[Y/n]' }
176
+ $reply = Read-Line ("? $Prompt $hint ")
177
+ if ($null -eq $reply) { return $false }
178
+ $reply = $reply.Trim()
179
+ if ($reply -eq '') { $reply = $Default }
180
+ return ($reply -match '^(y|yes)$')
181
+ }
182
+
183
+ function Confirm-Typed {
184
+ <# .SYNOPSIS Critical confirmation: the user must type the literal word. Never auto-answered. #>
185
+ param([string] $Prompt, [string] $Word = 'yes')
186
+ if (-not $Script:WS.Interactive) { Write-Note "non-interactive session: '$Prompt' requires a typed '$Word'; skipped"; return $false }
187
+ Write-Warn $Prompt
188
+ $reply = Read-Line (" Type '$Word' to proceed: ")
189
+ if ($null -eq $reply) { return $false }
190
+ return ($reply.Trim() -eq $Word)
191
+ }
192
+
193
+ function Read-MultiSelect {
194
+ <# .SYNOPSIS Parse "1,3,5-7" / all / none into a sorted unique list of 1-based indexes.
195
+ Order of answers: a --select-file path list matched against -Candidates, then the next queued --select
196
+ list, then --yes (only for callers that do not pass -NoAutoYes), then the console, then nothing.
197
+ --yes NEVER selects for the personal and project pickers: they pass -NoAutoYes, so under --yes they
198
+ still ask when a console is present and select nothing when it is not. #>
199
+ param([int] $Total, [switch] $NoAutoYes, [string[]] $Candidates = @())
200
+ $ws = $Script:WS
201
+ $ws.LastSelectionScripted = $false
202
+ if ($Total -le 0) { return @() }
203
+ if ($ws.SelectPaths.Count -gt 0 -and $Candidates.Count -gt 0) {
204
+ $picked = @(Resolve-SelectedPaths -Candidates $Candidates)
205
+ $ws.LastSelectionScripted = $true
206
+ Write-Info ("--select-file matched $($picked.Count) of $Total candidate(s)")
207
+ return $picked
208
+ }
209
+ if ($ws.SelectQueue.Count -gt 0) {
210
+ $spec = [string]$ws.SelectQueue[0]
211
+ $ws.SelectQueue = @($ws.SelectQueue | Select-Object -Skip 1)
212
+ $picked = @(ConvertTo-IndexList -Text $spec -Total $Total)
213
+ $ws.LastSelectionScripted = $true
214
+ Write-Info ("--select '$spec' -> $($picked.Count) of $Total item(s)")
215
+ return $picked
216
+ }
217
+ if ($ws.Yes -and -not $NoAutoYes) { return @(1..$Total) }
218
+ if (-not $ws.Interactive) { Write-Note 'non-interactive session: nothing selected (a person has to pick these)'; return @() }
219
+ Write-UiLine " Selection: 1,3,5-10 | all | none (Enter = none)" 'DarkGray' -NoLog
220
+ $reply = Read-Line (" Select items (1..$Total): ")
221
+ return (ConvertTo-IndexList -Text $reply -Total $Total)
222
+ }
223
+
224
+ function Resolve-SelectedPaths {
225
+ <# .SYNOPSIS Match the --select-file lines against this prompt's candidate paths, case-insensitively.
226
+ Returns 1-based indexes into $Candidates. A line matching nothing here is reported once and skipped:
227
+ the same file is offered to every prompt, so an unmatched line is normal and never fatal. #>
228
+ param([string[]] $Candidates)
229
+ $set = New-Object System.Collections.Generic.SortedSet[int]
230
+ $matched = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
231
+ for ($i = 0; $i -lt $Candidates.Count; $i++) {
232
+ foreach ($want in $Script:WS.SelectPaths) {
233
+ if ([string]$Candidates[$i] -eq $want) { $null = $set.Add($i + 1); $null = $matched.Add($want) }
234
+ }
235
+ }
236
+ foreach ($want in $Script:WS.SelectPaths) {
237
+ if (-not $matched.Contains($want)) { Write-Note "--select-file: no candidate here matches $want" }
238
+ }
239
+ return @($set)
240
+ }
241
+
242
+ function ConvertTo-IndexList {
243
+ <# .SYNOPSIS Pure parser behind Read-MultiSelect (kept separate so the self-test can exercise it). #>
244
+ param([string] $Text, [int] $Total)
245
+ if ([string]::IsNullOrWhiteSpace($Text)) { return @() }
246
+ $t = $Text.Trim().ToLowerInvariant()
247
+ if ($t -in @('n', 'none')) { return @() }
248
+ if ($t -in @('a', 'all')) { return @(1..$Total) }
249
+ $set = New-Object System.Collections.Generic.SortedSet[int]
250
+ foreach ($part in ($t -split ',')) {
251
+ $part = $part.Trim()
252
+ if ($part -match '^(\d+)-(\d+)$') {
253
+ $a = [int]$Matches[1]; $b = [int]$Matches[2]
254
+ if ($a -gt $b) { $tmp = $a; $a = $b; $b = $tmp }
255
+ for ($i = $a; $i -le $b; $i++) { if ($i -ge 1 -and $i -le $Total) { $null = $set.Add($i) } }
256
+ } elseif ($part -match '^\d+$') {
257
+ $i = [int]$part
258
+ if ($i -ge 1 -and $i -le $Total) { $null = $set.Add($i) }
259
+ }
260
+ }
261
+ return @($set)
262
+ }
263
+
264
+ function Read-Choice {
265
+ <# .SYNOPSIS One-key choice; returns the lower-cased key or the default. #>
266
+ param([string] $Prompt, [string] $Default = '')
267
+ $reply = Read-Line $Prompt
268
+ if ($null -eq $reply -or $reply.Trim() -eq '') { return $Default }
269
+ return $reply.Trim().ToLowerInvariant()
270
+ }
271
+
272
+ function Wait-Enter {
273
+ if (-not $Script:WS.Interactive) { return }
274
+ $null = Read-Line " - press Enter to continue - "
275
+ }
@@ -1,36 +1,36 @@
1
- # android_avd.ps1 - section 4: Android emulators (AVDs). Slow to recreate, so per-AVD idle gate and never in --all.
2
-
3
- function Get-Targets04 {
4
- return @((New-Target 4 'Android AVDs (each .avd + .ini pair)' "$($Script:P.U)\.android\avd" -Mode units -Dev $true -Note "an AVD goes only when nothing inside it changed for $($Script:WS.Days)+ days"))
5
- }
6
-
7
- function Invoke-Section04 {
8
- $ws = $Script:WS
9
- $root = Join-Path $Script:P.U '.android\avd'
10
- Write-SectionIntro @(
11
- 'An emulator image is 5-15 GB and takes minutes to recreate. Each AVD is one unit: it is removed only when',
12
- "every file inside it has been idle for $($ws.Days)+ days (booting an emulator updates its files)."
13
- ) -Dev $true
14
- if (-not (Test-DirPresent $root)) { Write-Info 'no AVDs found'; return }
15
- if (Test-ProcessRunning @('qemu-system-x86_64', 'emulator', 'emulator64-crash-service')) { Write-Warn 'an Android emulator is running - section skipped'; return }
16
- $avds = @()
17
- foreach ($e in (Get-ChildEntries $root)) {
18
- if (-not ($e -is [IO.DirectoryInfo]) -or $e.Name -notlike '*.avd') { continue }
19
- $full = Remove-LongPrefix $e.FullName
20
- $avds += [pscustomobject]@{ Name = $e.Name; Path = $full; Ini = (Join-Path $root ($e.Name -replace '\.avd$', '.ini')); Idle = (Get-IdleDays $full); Bytes = (Get-DirectoryBytes $full) }
21
- }
22
- if ($avds.Count -eq 0) { Write-Info 'no AVDs found'; return }
23
- Write-UiLine (" {0,-36} {1,10} {2,8}" -f 'AVD', 'SIZE', 'IDLE') 'White'
24
- foreach ($a in $avds) { Write-UiLine (" {0,-36} {1,10} {2,7}d" -f $a.Name, (Format-Bytes $a.Bytes), $a.Idle) 'Gray' }
25
- $avds = @($avds)
26
- $stale = @($avds | Where-Object { $_.Idle -ge $ws.Days -or $ws.PurgeAll })
27
- if ($stale.Count -eq 0) { Write-Info "every AVD was used within the last $($ws.Days) days - nothing to prune"; return }
28
- if (-not (Confirm-Section "Remove $($stale.Count) idle AVD(s)?" 'n')) { Write-Info 'skipped'; return }
29
- foreach ($a in $stale) {
30
- $r = Remove-PathSafe -Path $a.Path -Within $root -Label $a.Name
31
- if ($r.Removed) {
32
- if (Test-PathPresent $a.Ini) { $null = Remove-PathSafe -Path $a.Ini -Within $root -Label "$($a.Name) ini" }
33
- if (-not $ws.DryRun) { Write-Ok ("removed AVD $($a.Name) ($($a.Idle)d idle, " + (Format-Bytes $r.Bytes) + ')') }
34
- }
35
- }
36
- }
1
+ # android_avd.ps1 - section 4: Android emulators (AVDs). Slow to recreate, so per-AVD idle gate and never in --all.
2
+
3
+ function Get-Targets04 {
4
+ return @((New-Target 4 'Android AVDs (each .avd + .ini pair)' "$($Script:P.U)\.android\avd" -Mode units -Dev $true -Note "an AVD goes only when nothing inside it changed for $($Script:WS.Days)+ days"))
5
+ }
6
+
7
+ function Invoke-Section04 {
8
+ $ws = $Script:WS
9
+ $root = Join-Path $Script:P.U '.android\avd'
10
+ Write-SectionIntro @(
11
+ 'An emulator image is 5-15 GB and takes minutes to recreate. Each AVD is one unit: it is removed only when',
12
+ "every file inside it has been idle for $($ws.Days)+ days (booting an emulator updates its files)."
13
+ ) -Dev $true
14
+ if (-not (Test-DirPresent $root)) { Write-Info 'no AVDs found'; return }
15
+ if (Test-ProcessRunning @('qemu-system-x86_64', 'emulator', 'emulator64-crash-service')) { Write-Warn 'an Android emulator is running - section skipped'; return }
16
+ $avds = @()
17
+ foreach ($e in (Get-ChildEntries $root)) {
18
+ if (-not ($e -is [IO.DirectoryInfo]) -or $e.Name -notlike '*.avd') { continue }
19
+ $full = Remove-LongPrefix $e.FullName
20
+ $avds += [pscustomobject]@{ Name = $e.Name; Path = $full; Ini = (Join-Path $root ($e.Name -replace '\.avd$', '.ini')); Idle = (Get-IdleDays $full); Bytes = (Get-DirectoryBytes $full) }
21
+ }
22
+ if ($avds.Count -eq 0) { Write-Info 'no AVDs found'; return }
23
+ Write-UiLine (" {0,-36} {1,10} {2,8}" -f 'AVD', 'SIZE', 'IDLE') 'White'
24
+ foreach ($a in $avds) { Write-UiLine (" {0,-36} {1,10} {2,7}d" -f $a.Name, (Format-Bytes $a.Bytes), $a.Idle) 'Gray' }
25
+ $avds = @($avds)
26
+ $stale = @($avds | Where-Object { $_.Idle -ge $ws.Days -or $ws.PurgeAll })
27
+ if ($stale.Count -eq 0) { Write-Info "every AVD was used within the last $($ws.Days) days - nothing to prune"; return }
28
+ if (-not (Confirm-Section "Remove $($stale.Count) idle AVD(s)?" 'n')) { Write-Info 'skipped'; return }
29
+ foreach ($a in $stale) {
30
+ $r = Remove-PathSafe -Path $a.Path -Within $root -Label $a.Name
31
+ if ($r.Removed) {
32
+ if (Test-PathPresent $a.Ini) { $null = Remove-PathSafe -Path $a.Ini -Within $root -Label "$($a.Name) ini" }
33
+ if (-not $ws.DryRun) { Write-Ok ("removed AVD $($a.Name) ($($a.Idle)d idle, " + (Format-Bytes $r.Bytes) + ')') }
34
+ }
35
+ }
36
+ }