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.
- package/AI-INTEGRATION-GUIDE.md +15 -11
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -21
- package/README.md +70 -42
- package/VERSION +1 -1
- package/lib/actions.ps1 +212 -212
- package/lib/config.ps1 +108 -99
- package/lib/constants.ps1 +103 -93
- package/lib/fs.ps1 +189 -180
- package/lib/log.ps1 +140 -140
- package/lib/safety.ps1 +541 -493
- package/lib/scan.ps1 +109 -100
- package/lib/ui.ps1 +275 -275
- package/modules/android_avd.ps1 +36 -36
- package/modules/app_caches.ps1 +58 -58
- package/modules/browsers.ps1 +33 -33
- package/modules/build_tools.ps1 +35 -35
- package/modules/crash_trap.ps1 +89 -89
- package/modules/disk_usage.ps1 +49 -49
- package/modules/docker.ps1 +93 -93
- package/modules/editors.ps1 +150 -150
- package/modules/globals_audit.ps1 +236 -236
- package/modules/health.ps1 +101 -101
- package/modules/installed_programs.ps1 +118 -118
- package/modules/menu.ps1 +63 -63
- package/modules/notify.ps1 +60 -60
- package/modules/orphaned_appdata.ps1 +189 -189
- package/modules/personal.ps1 +126 -126
- package/modules/pkg_managers.ps1 +73 -73
- package/modules/projects.ps1 +160 -160
- package/modules/release_helpers.ps1 +438 -438
- package/modules/reports.ps1 +212 -212
- package/modules/runner.ps1 +242 -239
- package/modules/self_test_extra.ps1 +537 -360
- package/modules/startup_audit.ps1 +126 -126
- package/modules/system_admin.ps1 +220 -220
- package/modules/test_runners.ps1 +24 -24
- package/modules/walkthrough.ps1 +80 -79
- package/modules/windows_user.ps1 +118 -118
- package/package.json +2 -2
- 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) -
|
|
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
|
+
}
|
package/modules/android_avd.ps1
CHANGED
|
@@ -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
|
+
}
|