windowsweep 1.0.0 → 1.1.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,189 @@
1
+ # orphaned_appdata.ps1 - section 23: folders directly under %APPDATA% and %LOCALAPPDATA% that belong to no
2
+ # installed program. Interactive only; selected folders go to the Recycle Bin.
3
+ #
4
+ # The dangerous direction here is calling something orphaned when it is not, so every test errs the other
5
+ # way: matching is generous, the exclusion list is long, and an EMPTY program index produces ZERO candidates
6
+ # rather than "everything is orphaned".
7
+
8
+ # Windows' own folders, shared infrastructure, and vendors whose data outlives an uninstall.
9
+ $Script:WS_ORPHAN_EXCLUDE = @(
10
+ 'Microsoft', 'Windows', 'WindowsPowerShell', 'PowerShell', 'Packages', 'Programs', 'Temp', 'Tmp', 'Comms',
11
+ 'ConnectedDevicesPlatform', 'D3DSCache', 'Google', 'Mozilla', 'CrashDumps', 'VirtualStore', 'PeerDistRepub',
12
+ 'Publishers', 'History', 'IconCache', 'ElevatedDiagnostics', 'Application Data', 'Local Settings',
13
+ 'Deployment', 'assembly', 'GroupPolicy', 'Roaming', 'Local', 'LocalLow', 'Low', 'Diagnostics',
14
+ 'PlaceholderTileLogoFolder', 'Desktop', 'Downloads', 'Documents', 'Start Menu', 'SendTo',
15
+ 'Recent', 'NetHood', 'PrintHood', 'Templates', 'Cookies',
16
+ # Platform folders that belong to Windows or .NET rather than to any one program: they look orphaned
17
+ # because no uninstall entry names them, and one of them holds settings a .NET app wrote.
18
+ 'IsolatedStorage', 'ToastNotificationManagerCompat', 'AppV', 'Package Cache', 'DBG', 'CLR_v4.0', 'CLR_v4.0_32'
19
+ )
20
+
21
+ function ConvertTo-MatchToken {
22
+ <# .SYNOPSIS Lower-case, alphanumerics only - so "Slack Technologies, Inc." and "slack" compare equal. #>
23
+ param([string] $Text)
24
+ if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
25
+ return ($Text.ToLowerInvariant() -replace '[^a-z0-9]', '')
26
+ }
27
+
28
+ function Add-MatchTokens {
29
+ <# .SYNOPSIS Add a display string to the token set: the whole thing, plus each word of 4+ characters. #>
30
+ param([System.Collections.Generic.HashSet[string]] $Set, [string] $Text)
31
+ $whole = ConvertTo-MatchToken $Text
32
+ if ($whole.Length -ge 3) { $null = $Set.Add($whole) }
33
+ foreach ($w in ($Text -split '[^A-Za-z0-9]+')) {
34
+ $t = ConvertTo-MatchToken $w
35
+ if ($t.Length -ge 4) { $null = $Set.Add($t) }
36
+ }
37
+ }
38
+
39
+ function Get-InstalledProgramRows {
40
+ <# .SYNOPSIS Raw uninstall-hive rows from all three registry views. Shared with section 24. #>
41
+ $rows = @()
42
+ $hives = @(
43
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
44
+ 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
45
+ 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
46
+ )
47
+ foreach ($h in $hives) {
48
+ $keys = @()
49
+ try { $keys = @(Get-ChildItem -LiteralPath $h -ErrorAction Stop) } catch { $keys = @() }
50
+ foreach ($k in $keys) {
51
+ $props = $null
52
+ try { $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction Stop } catch { continue }
53
+ if (-not $props.DisplayName) { continue }
54
+ $rows += [pscustomobject]@{
55
+ DisplayName = [string]$props.DisplayName; DisplayVersion = [string]$props.DisplayVersion
56
+ Publisher = [string]$props.Publisher; InstallDate = [string]$props.InstallDate
57
+ EstimatedSizeKb = [long]0 + $props.EstimatedSize; InstallLocation = [string]$props.InstallLocation
58
+ UninstallString = [string]$props.UninstallString; SystemComponent = [int]0 + $props.SystemComponent
59
+ }
60
+ }
61
+ }
62
+ return $rows
63
+ }
64
+
65
+ function Get-InstalledProgramIndex {
66
+ <# .SYNOPSIS Everything that says "a program owns this name": uninstall entries, program folders, Store
67
+ package names and running processes. RegistryCount drives the fail-closed gate in section 23. #>
68
+ $P = $Script:P
69
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
70
+ $rows = @(Get-InstalledProgramRows)
71
+ foreach ($r in $rows) {
72
+ Add-MatchTokens -Set $set -Text $r.DisplayName
73
+ Add-MatchTokens -Set $set -Text $r.Publisher
74
+ if ($r.InstallLocation) { Add-MatchTokens -Set $set -Text (Split-Path -Leaf ($r.InstallLocation.TrimEnd('\'))) }
75
+ }
76
+ foreach ($dir in @($P.PF, $P.PF86, (Join-Path $P.L 'Programs'), (Join-Path $P.L 'Microsoft\WindowsApps'), (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs'))) {
77
+ if (-not $dir -or -not (Test-DirPresent $dir)) { continue }
78
+ foreach ($e in (Get-ChildEntries $dir)) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
79
+ }
80
+ foreach ($e in (Get-ChildEntries (Join-Path $P.L 'Packages'))) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
81
+ try { foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { Add-MatchTokens -Set $set -Text $proc.ProcessName } } catch { $null = $_ }
82
+ return [pscustomobject]@{ Tokens = $set; RegistryCount = $rows.Count; Rows = $rows }
83
+ }
84
+
85
+ function Get-OrphanExclusions {
86
+ <# .SYNOPSIS Folder names section 23 never offers: the fixed list plus, derived at run time, the first
87
+ segment under AppData of every target the tool already declares - so a vendor folder we clean can never
88
+ also be offered for deletion, and the list cannot drift as sections are added. #>
89
+ $P = $Script:P
90
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
91
+ foreach ($n in $Script:WS_ORPHAN_EXCLUDE) { $null = $set.Add($n) }
92
+ $prefixes = @("$($P.A)\", "$($P.L)\")
93
+ foreach ($t in (Get-AllTargets)) {
94
+ $path = [string]$t.Path
95
+ foreach ($pre in $prefixes) {
96
+ if ($path.StartsWith($pre, [StringComparison]::OrdinalIgnoreCase)) {
97
+ $seg = ($path.Substring($pre.Length) -split '\\')[0]
98
+ if ($seg) { $null = $set.Add($seg) }
99
+ }
100
+ }
101
+ }
102
+ # ,$set - a bare `return $set` UNROLLS the HashSet into an Object[], whose .Contains is case-SENSITIVE,
103
+ # so a folder named "slack" would slip past the "Slack" entry. The comma keeps the set a set.
104
+ return , $set
105
+ }
106
+
107
+ function Test-OrphanFolderName {
108
+ <# .SYNOPSIS True when NOTHING installed claims this folder name. Deliberately generous in the other
109
+ direction: a token that merely starts with the name (or the reverse) counts as a claim. #>
110
+ param([string] $Name, [pscustomobject] $Index)
111
+ $n = ConvertTo-MatchToken $Name
112
+ if ($n.Length -lt 3) { return $false }
113
+ if ($Index.Tokens.Contains($n)) { return $false }
114
+ foreach ($t in $Index.Tokens) {
115
+ if ($t.Length -lt 4) { continue }
116
+ if ($t.StartsWith($n) -or $n.StartsWith($t)) { return $false }
117
+ }
118
+ return $true
119
+ }
120
+
121
+ function Find-OrphanedAppData {
122
+ <# .SYNOPSIS Top-level AppData folders idle N+ days that no installed program claims. Fails closed.
123
+ -Index is for the self-test: it proves the fail-closed gate without touching the real registry. #>
124
+ param([int] $Days, [pscustomobject] $Index = $null)
125
+ $P = $Script:P
126
+ $index = $Index
127
+ if ($null -eq $index) { $index = Get-InstalledProgramIndex }
128
+ if ($index.RegistryCount -eq 0) {
129
+ Write-Warn 'no uninstall entries could be read: refusing to call anything orphaned (fail closed).'
130
+ return @()
131
+ }
132
+ Write-Note "cross-checked against $($index.RegistryCount) installed-program entries"
133
+ $exclude = Get-OrphanExclusions
134
+ $out = @()
135
+ foreach ($root in @($P.A, $P.L)) {
136
+ if (-not (Test-DirPresent $root)) { continue }
137
+ foreach ($e in (Get-ChildEntries $root)) {
138
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
139
+ if ($exclude.Contains($e.Name)) { continue }
140
+ $full = Remove-LongPrefix $e.FullName
141
+ if (Get-ProtectionReason $full) { continue }
142
+ if (-not (Test-OrphanFolderName -Name $e.Name -Index $index)) { continue }
143
+ $idle = Get-IdleDays $full
144
+ if ($idle -lt $Days) { continue }
145
+ $out += [pscustomobject]@{ Name = $e.Name; Path = $full; Root = (Get-FullPath $root); Idle = $idle; Bytes = [long](Get-DirectoryBytes $full) }
146
+ }
147
+ }
148
+ return @($out | Sort-Object Bytes -Descending | Select-Object -First 200)
149
+ }
150
+
151
+ function Get-Targets23 {
152
+ <# .SYNOPSIS Informational rows: the candidates are found at run time, never declared as fixed paths. #>
153
+ $P = $Script:P
154
+ $t = @()
155
+ foreach ($r in @($P.A, $P.L)) {
156
+ if (Test-DirPresent $r) { $t += (New-Target 23 'AppData root scanned for orphans' (Get-FullPath $r) -Kind cmd -Note 'top-level folders only; you pick what goes, and it lands in the Recycle Bin') }
157
+ }
158
+ return $t
159
+ }
160
+
161
+ function Invoke-Section23 {
162
+ $ws = $Script:WS
163
+ Write-SectionIntro @(
164
+ 'Uninstalling a program usually leaves its AppData folder behind. This lists top-level folders under',
165
+ "Roaming and Local that no installed program, Store package or running process claims and that nothing",
166
+ "has touched for $($ws.Days)+ days. You pick what goes; it lands in the Recycle Bin."
167
+ )
168
+ $rows = @(Find-OrphanedAppData -Days $ws.Days)
169
+ if ($rows.Count -eq 0) { Write-Info 'no orphaned application data found'; return }
170
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FOLDER') 'White'
171
+ $i = 1; $total = [long]0
172
+ foreach ($r in $rows) {
173
+ Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'
174
+ Add-JsonCandidate -Section 23 -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle
175
+ $total += $r.Bytes; $i++
176
+ }
177
+ Write-Info ('total: ' + (Format-Bytes $total))
178
+ Write-Note 'a folder here can still hold settings or a licence for a program you plan to reinstall - read the list.'
179
+ $dest = 'the Recycle Bin'
180
+ if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
181
+ Write-Note "selected folders go to $dest"
182
+ $picks = @(Read-MultiSelect -Total $rows.Count -NoAutoYes -Candidates @($rows | ForEach-Object { $_.Path }))
183
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
184
+ if (-not $ws.DryRun) {
185
+ # Orphaned app data never auto-confirms: --yes does not apply. An explicit --select / --select-file does.
186
+ if (-not (Confirm-Ui -Prompt "Move $($picks.Count) folder(s) to $dest ?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
187
+ }
188
+ foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $rows[$k - 1].Path -Within $rows[$k - 1].Root }
189
+ }
@@ -73,22 +73,24 @@ function Show-FileTable {
73
73
  }
74
74
 
75
75
  function Invoke-PersonalPicker {
76
- param([object[]] $Rows, [string] $Prompt)
76
+ param([object[]] $Rows, [string] $Prompt, [int] $Section = 0)
77
77
  $ws = $Script:WS
78
78
  $Rows = @($Rows)
79
79
  if ($Rows.Count -eq 0) { Write-Info 'none found'; return }
80
80
  Show-FileTable $Rows
81
81
  $total = [long]0
82
- foreach ($r in $Rows) { $total += $r.Bytes }
82
+ $i = 1
83
+ foreach ($r in $Rows) { $total += $r.Bytes; Add-JsonCandidate -Section $Section -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle; $i++ }
83
84
  Write-Info ("total: " + (Format-Bytes $total))
84
85
  $dest = 'the Recycle Bin'
85
86
  if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
86
87
  Write-Note "selected files go to $dest"
87
- $picks = Read-MultiSelect -Total $Rows.Count
88
+ $picks = @(Read-MultiSelect -Total $Rows.Count -NoAutoYes -Candidates @($Rows | ForEach-Object { $_.Path }))
88
89
  if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
89
90
  if (-not $ws.DryRun) {
90
- # Personal files never auto-confirm: --yes does not apply here.
91
- if (-not (Confirm-Ui -Prompt "$Prompt ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes)) { Write-Info 'skipped'; return }
91
+ # Personal files never auto-confirm: --yes does not apply here. An explicit --select / --select-file
92
+ # choice does, because it is a person naming these exact files in advance.
93
+ if (-not (Confirm-Ui -Prompt "$Prompt ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
92
94
  }
93
95
  foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $Rows[$k - 1].Path -Within $Rows[$k - 1].Root }
94
96
  }
@@ -99,7 +101,7 @@ function Invoke-Section18 {
99
101
  'You pick what goes; it lands in the Recycle Bin. Desktop and cloud-synced folders are never scanned.'
100
102
  )
101
103
  if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
102
- Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin'
104
+ Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin' -Section 18
103
105
  }
104
106
 
105
107
  function Invoke-Section19 {
@@ -109,7 +111,7 @@ function Invoke-Section19 {
109
111
  'archives you already extracted. You pick what goes; it lands in the Recycle Bin.'
110
112
  )
111
113
  if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
112
- Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin'
114
+ Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin' -Section 19
113
115
  }
114
116
 
115
117
  function Show-PersonalScan {
@@ -37,6 +37,10 @@ function Get-Targets01 {
37
37
  (New-Target 1 'electron-builder cache' "$($P.L)\electron-builder\Cache" -Mode prune -Dev $true)
38
38
  (New-Target 1 'node-gyp headers cache' "$($P.L)\node-gyp\Cache" -Mode prune -Dev $true)
39
39
  (New-Target 1 'Scoop installer cache' "$($P.U)\scoop\cache" -Mode prune -Dev $true)
40
+ # Hugging Face keeps revisions as snapshots\<rev> linking into blobs, so removing a whole snapshot orphans
41
+ # blobs: this prunes by the idle gate instead of keeping "the newest per model". AI agent state under
42
+ # ~\.cache (claude, codex, gemini, copilot) stays protected by lib/safety.ps1.
43
+ (New-Target 1 'Hugging Face model cache' "$($P.U)\.cache\huggingface\hub" -Mode prune -Dev $true -Note 'models are re-downloaded on next use; the idle gate keeps recent ones')
40
44
  )
41
45
  }
42
46
 
@@ -1,6 +1,10 @@
1
1
  # projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
2
2
 
3
- $Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular')
3
+ $Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
4
+ # Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
5
+ # A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
6
+ # .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
7
+ $Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
4
8
  $Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
5
9
  $Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
6
10
 
@@ -37,6 +41,15 @@ function Get-Targets17 {
37
41
  return $t
38
42
  }
39
43
 
44
+ function Test-MarkedArtefactDir {
45
+ <# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
46
+ param([string] $Dir, [string] $Name)
47
+ if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
48
+ $names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
49
+ foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
50
+ return $false
51
+ }
52
+
40
53
  function Test-ProjectMarker {
41
54
  param([string] $Dir, [string] $ArtefactName)
42
55
  $entries = Get-ChildEntries $Dir
@@ -59,7 +72,7 @@ function Get-ProjectActivityDays {
59
72
  $seen++
60
73
  if (Test-ReparsePoint $e) { continue }
61
74
  if ($e -is [IO.DirectoryInfo]) {
62
- if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name) { continue }
75
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
63
76
  $stack.Push((Remove-LongPrefix $e.FullName)); continue
64
77
  }
65
78
  $t = Get-NewestTimestampUtc $e
@@ -88,7 +101,7 @@ function Find-StaleArtefacts {
88
101
  $skip = $false
89
102
  foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
90
103
  if ($skip) { continue }
91
- if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj')) {
104
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
92
105
  if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
93
106
  if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
94
107
  $age = $projectAge[$dir]
@@ -116,24 +129,31 @@ function Invoke-Section17 {
116
129
  $found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
117
130
  if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
118
131
  $reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
132
+ # One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
133
+ $offered = @($found | Select-Object -First 200)
134
+ if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
119
135
  Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
120
136
  $i = 1; $total = [long]0
121
- foreach ($f in $found) {
137
+ foreach ($f in $offered) {
122
138
  Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
123
139
  $reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
140
+ Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
124
141
  $total += $f.Bytes; $i++
125
- if ($i -gt 200) { Write-Warn 'list truncated to 200 entries'; break }
126
142
  }
127
143
  Write-Info ("total: " + (Format-Bytes $total))
128
144
  if (-not $ws.NoReport) {
129
145
  $out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
130
146
  try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
131
147
  }
132
- $picks = Read-MultiSelect -Total ([math]::Min($found.Count, 200))
148
+ $picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
133
149
  if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
134
- if (-not (Confirm-Section "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" 'n')) { Write-Info 'skipped'; return }
150
+ if (-not $ws.DryRun) {
151
+ # Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
152
+ # An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
153
+ if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
154
+ }
135
155
  foreach ($k in $picks) {
136
- $f = $found[$k - 1]
156
+ $f = $offered[$k - 1]
137
157
  $r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
138
158
  if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
139
159
  }
@@ -18,6 +18,9 @@ function Show-Version {
18
18
  }
19
19
 
20
20
  function Show-SectionList {
21
+ # --list --json prints the catalogue and nothing else: the human table below uses Write-Host, which would
22
+ # break the one-line-on-stdout promise.
23
+ if ($Script:WS.JsonMode) { [Console]::Out.WriteLine(((Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress)); return }
21
24
  Write-Host ''
22
25
  Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f '#', 'SECTION', 'TIER', 'ADMIN', 'BATCH')
23
26
  foreach ($s in $Script:WS_SECTIONS) {
@@ -241,6 +244,12 @@ function Invoke-SelfTest {
241
244
  try { $probe = Join-Path $d ('.write-test-' + [guid]::NewGuid().ToString('N')); Set-Content -LiteralPath $probe -Value 'x'; Remove-Item -LiteralPath $probe -Force; Write-Ok "$d writable" } catch { Write-Err "$d not writable"; $fails++ }
242
245
  }
243
246
 
247
+ if (Get-Command Invoke-SelfTestExtra -ErrorAction SilentlyContinue) {
248
+ $extra = Invoke-SelfTestExtra
249
+ $checks += [int]$extra.Checks
250
+ $fails += [int]$extra.Fails
251
+ }
252
+
244
253
  Write-Separator
245
254
  if ($fails -eq 0) { Write-Ok "all $checks checks passed - $Script:WS_NAME is ready"; return $Script:WS_EXIT_OK }
246
255
  Write-Err "$fails of $checks checks FAILED"
@@ -317,11 +326,25 @@ function Get-LaunchCommand {
317
326
  return [pscustomobject]@{ Exe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; Args = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', "`"$($ws.ScriptPath)`"") }
318
327
  }
319
328
 
329
+ function Test-NpxInstallerRefusal {
330
+ <# .SYNOPSIS Under npx the package lives in a cache npm evicts; a task or alias pointing there would break later. #>
331
+ param([string] $Flag)
332
+ if (-not $Script:WS.Npx) { return $false }
333
+ Write-Err "$Flag is refused under npx: it would point at the npx cache, which npm evicts."
334
+ Write-Plain ' Install once, then register from the global command:'
335
+ Write-Plain ' npm install -g windowsweep'
336
+ Write-Plain " windowsweep $Flag"
337
+ $Script:WS.ExitCode = $Script:WS_EXIT_REFUSED
338
+ return $true
339
+ }
340
+
320
341
  function Install-WeeklyTask {
321
342
  Write-Box 'Install weekly Scheduled Task' "$Script:WS_TASK_NAME - Sundays 03:00, safe batch, no prompts"
343
+ if (Test-NpxInstallerRefusal '--install-task') { return }
322
344
  if (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue) { Write-Info 'task already exists'; return }
323
345
  $lc = Get-LaunchCommand
324
- $argLine = (@($lc.Args) + @('--all', '--yes', '--quiet', '--no-color')) -join ' '
346
+ # --notify is part of the task action: an unattended Sunday run that says nothing is a run nobody sees.
347
+ $argLine = (@($lc.Args) + @('--all', '--yes', '--quiet', '--no-color', '--notify')) -join ' '
325
348
  Write-Info "action: $($lc.Exe) $argLine"
326
349
  if ($Script:WS.DryRun) { Write-DryRun 'would register the task'; return }
327
350
  if (-not (Confirm-Ui -Prompt 'Register this task for your user account?' -Default 'y')) { Write-Info 'skipped'; return }
@@ -347,6 +370,7 @@ $Script:WS_ALIAS_MARK = '# windowsweep alias'
347
370
 
348
371
  function Install-ProfileAlias {
349
372
  Write-Box 'Install profile alias' "Adds a 'cleanup' function to your PowerShell profile"
373
+ if (Test-NpxInstallerRefusal '--install-alias') { return }
350
374
  $profilePath = $PROFILE.CurrentUserAllHosts
351
375
  $lc = Get-LaunchCommand
352
376
  $line = "function cleanup { & `"$($lc.Exe)`" $($lc.Args -join ' ') @args }"
@@ -388,7 +412,7 @@ function Remove-ToolData {
388
412
  $bytes = Get-DirectoryBytes $ws.Home
389
413
  Write-Info ("$($ws.Home) holds " + (Format-Bytes $bytes) + ' of logs, reports, bundles and config')
390
414
  if ($ws.DryRun) { Write-DryRun "would remove $($ws.Home)"; return }
391
- if (-not (Confirm-Ui -Prompt 'Delete it all (logs, reports, your developer answer)?' -Default 'n')) { Write-Info 'skipped'; return }
415
+ if (-not (Confirm-Ui -Prompt 'Delete it all (logs, reports, your developer answer)?' -Default 'n' -NoAutoYes)) { Write-Info 'skipped'; return }
392
416
  $ws.LogFile = $null
393
417
  try { [IO.Directory]::Delete((Get-LongPath $ws.Home), $true); Write-Ok 'removed' } catch { Write-Err "could not remove: $($_.Exception.Message)" }
394
418
  }
@@ -42,6 +42,29 @@ function Get-RequestedSections {
42
42
  return @($ids | Sort-Object -Unique)
43
43
  }
44
44
 
45
+ function Get-MachineProgressLine {
46
+ <# .SYNOPSIS The progress-line format a --json caller parses. Kept pure so the self-test can round-trip it. #>
47
+ param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
48
+ $line = "##$Script:WS_NAME section=$Section event=$Stage"
49
+ if ($Stage -eq 'end') { $line += " status=$Status freed_bytes=$Freed" }
50
+ return $line
51
+ }
52
+
53
+ function Write-MachineProgress {
54
+ <# .SYNOPSIS One machine-readable progress line on stderr, --json mode only, so a GUI need not parse the log.
55
+ Never on stdout: --json promises exactly one stdout line. #>
56
+ param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
57
+ if (-not $Script:WS.JsonMode) { return }
58
+ [Console]::Error.WriteLine((Get-MachineProgressLine -Section $Section -Stage $Stage -Status $Status -Freed $Freed))
59
+ }
60
+
61
+ function Add-JsonCandidate {
62
+ <# .SYNOPSIS Record one item an interactive section offered, so --json callers can present the same list. #>
63
+ param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = '')
64
+ if (-not $Script:WS.JsonMode) { return }
65
+ $Script:WS.Candidates += [ordered]@{ section = $Section; index = $Index; path = $Path; bytes = $Bytes; idle_days = $IdleDays; project = $Project }
66
+ }
67
+
45
68
  function Invoke-SectionById {
46
69
  <# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
47
70
  param([int] $Id)
@@ -50,18 +73,24 @@ function Invoke-SectionById {
50
73
  if (-not $sec) { Write-Warn "no section $Id"; return }
51
74
  $title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
52
75
  Write-Box $title
76
+ Write-MachineProgress -Section $Id -Stage 'start'
53
77
  # Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
54
78
  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."
79
+ if ($sec.Batch -eq 'interactive' -and -not $ws.SelectActive) {
80
+ Write-Warn "section $Id is interactive-only: it needs a person at the keyboard, or a selection passed with --select / --select-file."
57
81
  $ws.Refusals += "section $Id (interactive-only)"
58
82
  Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'interactive-only section in batch mode'
83
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
59
84
  return
60
85
  }
86
+ if ($sec.Batch -eq 'interactive') {
87
+ Write-Note "section $Id runs unattended because a selection was supplied (--select / --select-file): a person chose these."
88
+ }
61
89
  if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
62
90
  Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
63
91
  $ws.Refusals += "section $Id (deep, add --i-understand-deep)"
64
92
  Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'deep section without --i-understand-deep'
93
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
65
94
  return
66
95
  }
67
96
  }
@@ -70,11 +99,13 @@ function Invoke-SectionById {
70
99
  Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
71
100
  $ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
72
101
  Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
102
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
73
103
  return
74
104
  }
75
105
  if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
76
106
  Write-Info "developer mode is off - section $Id (developer-only data) skipped."
77
107
  Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
108
+ Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
78
109
  return
79
110
  }
80
111
  $ws.SectionFreed = [long]0
@@ -86,7 +117,7 @@ function Invoke-SectionById {
86
117
  } catch {
87
118
  $status = 'failed'
88
119
  Write-Err "section $Id failed: $($_.Exception.Message)"
89
- Write-Log ("section $Id error: " + $_.ScriptStackTrace)
120
+ Write-LogLine ("section $Id error: " + $_.ScriptStackTrace)
90
121
  $ws.ExitCode = $Script:WS_EXIT_FAIL
91
122
  }
92
123
  $after = Get-SystemDriveFree
@@ -101,6 +132,7 @@ function Invoke-SectionById {
101
132
  Write-Note 'no measurable change in this section'
102
133
  }
103
134
  Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
135
+ Write-MachineProgress -Section $Id -Stage 'end' -Status $status -Freed $freed
104
136
  }
105
137
 
106
138
  function Invoke-BatchMode {
@@ -173,18 +205,35 @@ function Show-SessionSummary {
173
205
  }
174
206
  Write-UiLine '' 'Gray'
175
207
  Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
208
+ Send-RunNotification
176
209
  if ($ws.JsonMode) { Write-JsonSummary }
177
210
  }
178
211
 
179
- function Write-JsonSummary {
180
- <# .SYNOPSIS --json: the only line written to stdout. #>
212
+ function Get-JsonSummary {
213
+ <# .SYNOPSIS The --json document (ordered), kept apart from the printer so the self-test can parse it. #>
181
214
  $ws = $Script:WS
182
- $doc = [ordered]@{
215
+ return [ordered]@{
183
216
  tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
184
217
  elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
185
218
  freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
186
219
  sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
220
+ # candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
221
+ candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
187
222
  refusals = @($ws.Refusals); log_file = $ws.LogFile; report_file = $ws.ReportFile
188
223
  }
189
- [Console]::Out.WriteLine(($doc | ConvertTo-Json -Depth 5 -Compress))
224
+ }
225
+
226
+ function Get-CatalogueJson {
227
+ <# .SYNOPSIS --list --json: the section catalogue, so a front end reads it instead of hard-coding it. #>
228
+ return [ordered]@{
229
+ tool = $Script:WS_NAME; version = (Get-ToolVersion)
230
+ sections = @($Script:WS_SECTIONS | ForEach-Object { [ordered]@{ id = $_.Id; key = $_.Key; title = $_.Title; tier = $_.Tier; admin = [bool]$_.Admin; batch = $_.Batch; dev = [bool]$_.Dev } })
231
+ safe_batch = @($Script:WS_SAFE_BATCH); safe_batch_admin = @($Script:WS_SAFE_BATCH_ADMIN)
232
+ profiles = ([ordered]@{} + $Script:WS_PROFILES); walkthrough = @($Script:WS_WALKTHROUGH); walkthrough_admin = @($Script:WS_WALKTHROUGH_ADMIN)
233
+ }
234
+ }
235
+
236
+ function Write-JsonSummary {
237
+ <# .SYNOPSIS --json: the only line written to stdout. #>
238
+ [Console]::Out.WriteLine(((Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress))
190
239
  }