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.
package/lib/scan.ps1 CHANGED
@@ -16,10 +16,11 @@ function New-Target {
16
16
  }
17
17
 
18
18
  function Get-AllTargets {
19
- <# .SYNOPSIS Collect every declared target from the loaded modules (Get-Targets00 .. Get-Targets21). #>
19
+ <# .SYNOPSIS Collect every declared target from the loaded modules: one Get-TargetsNN per catalogue row.
20
+ Driven by WS_SECTIONS, never a literal range, so a new section is reachable the moment it is declared. #>
20
21
  $all = @()
21
- for ($i = 0; $i -le 21; $i++) {
22
- $fn = 'Get-Targets{0:00}' -f $i
22
+ foreach ($s in $Script:WS_SECTIONS) {
23
+ $fn = 'Get-Targets{0:00}' -f $s.Id
23
24
  if (Get-Command $fn -ErrorAction SilentlyContinue) { $all += @(& $fn) }
24
25
  }
25
26
  return $all
@@ -57,7 +58,12 @@ function Show-ScanTable {
57
58
  $paths = @(Resolve-TargetPaths $t)
58
59
  if ($paths.Count -eq 0) { Write-UiLine (" {0,-46} {1,10} {2}" -f $t.Label, 'absent', $t.Path) 'DarkGray'; continue }
59
60
  $bytes = [long]0
60
- foreach ($p in $paths) { $bytes += Get-DirectoryBytes $p }
61
+ foreach ($p in $paths) {
62
+ # Sized once per path: a second Get-DirectoryBytes pass for --json would walk every target twice.
63
+ $b = [long](Get-DirectoryBytes $p)
64
+ $bytes += $b
65
+ if ($Script:WS.JsonMode) { $Script:WS.ScanTargets += [ordered]@{ section = $t.Section; label = $t.Label; path = $p; bytes = $b } }
66
+ }
61
67
  $secTotal += $bytes
62
68
  $shown = $t.Path
63
69
  if ($paths.Count -gt 1) { $shown = "$($t.Path) ($($paths.Count) matches)" }
package/lib/ui.ps1 CHANGED
@@ -37,7 +37,7 @@ function Write-UiLine {
37
37
  <# .SYNOPSIS Print one line (coloured when allowed) and append it to the log. #>
38
38
  param([string] $Text = '', [string] $Color = 'Gray', [switch] $NoLog, [switch] $NoNewline)
39
39
  $ws = $Script:WS
40
- if (-not $NoLog) { Write-Log $Text }
40
+ if (-not $NoLog) { Write-LogLine $Text }
41
41
  if ($ws.Mute) { return }
42
42
  if ($ws.JsonMode) { [Console]::Error.WriteLine($Text); return }
43
43
  if ($ws.Color) {
@@ -49,7 +49,7 @@ function Write-UiLine {
49
49
 
50
50
  function Write-Info {
51
51
  param([string] $Text)
52
- if ($Script:WS.Quiet) { Write-Log "INFO $Text"; return }
52
+ if ($Script:WS.Quiet) { Write-LogLine "INFO $Text"; return }
53
53
  Write-UiLine ("$($Script:WS.Glyph.info) $Text") 'Cyan'
54
54
  }
55
55
 
@@ -70,7 +70,7 @@ function Write-Err {
70
70
 
71
71
  function Write-Note {
72
72
  param([string] $Text)
73
- if ($Script:WS.Quiet) { Write-Log "NOTE $Text"; return }
73
+ if ($Script:WS.Quiet) { Write-LogLine "NOTE $Text"; return }
74
74
  Write-UiLine (" $Text") 'DarkGray'
75
75
  }
76
76
 
@@ -154,14 +154,18 @@ function Read-Line {
154
154
  if ($Prompt) { Write-UiLine $Prompt 'White' -NoNewline -NoLog }
155
155
  try { $line = [Console]::In.ReadLine() } catch { return $null }
156
156
  if ($null -eq $line) { return $null }
157
- Write-Log ("> $line")
157
+ Write-LogLine ("> $line")
158
158
  return $line
159
159
  }
160
160
 
161
161
  function Confirm-Ui {
162
- <# .SYNOPSIS y/N prompt. --yes auto-confirms (unless -NoAutoYes); non-interactive runs answer no. #>
163
- param([string] $Prompt = 'Proceed?', [string] $Default = 'n', [switch] $NoAutoYes)
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)
164
167
  $ws = $Script:WS
168
+ if ($ScriptedOk -and $ws.LastSelectionScripted) { Write-Warn "[scripted selection] $Prompt - yes (--select / --select-file)"; return $true }
165
169
  if ($ws.Yes -and -not $NoAutoYes) { Write-UiLine (" [auto-yes] $Prompt") 'DarkGray'; return $true }
166
170
  if (-not $ws.Interactive) {
167
171
  Write-Note "non-interactive session: '$Prompt' answered no (pass --yes to confirm in batch)"
@@ -187,15 +191,54 @@ function Confirm-Typed {
187
191
  }
188
192
 
189
193
  function Read-MultiSelect {
190
- <# .SYNOPSIS Parse "1,3,5-7" / all / none into a sorted unique list of 1-based indexes. #>
191
- param([int] $Total)
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
192
202
  if ($Total -le 0) { return @() }
193
- if ($Script:WS.Yes) { return @(1..$Total) }
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 @() }
194
219
  Write-UiLine " Selection: 1,3,5-10 | all | none (Enter = none)" 'DarkGray' -NoLog
195
220
  $reply = Read-Line (" Select items (1..$Total): ")
196
221
  return (ConvertTo-IndexList -Text $reply -Total $Total)
197
222
  }
198
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
+
199
242
  function ConvertTo-IndexList {
200
243
  <# .SYNOPSIS Pure parser behind Read-MultiSelect (kept separate so the self-test can exercise it). #>
201
244
  param([string] $Text, [int] $Total)
@@ -73,8 +73,8 @@ function Invoke-CrashBundle {
73
73
  $ws = $Script:WS
74
74
  $msg = $ErrorRecord.Exception.Message
75
75
  $where = $ErrorRecord.InvocationInfo.PositionMessage
76
- Write-Log "CRASH: $msg"
77
- Write-Log $ErrorRecord.ScriptStackTrace
76
+ Write-LogLine "CRASH: $msg"
77
+ Write-LogLine $ErrorRecord.ScriptStackTrace
78
78
  $extra = @("=== Error ===", $msg, $where, '', '=== Stack ===', $ErrorRecord.ScriptStackTrace) -join "`r`n"
79
79
  $zip = New-BundleZip -Prefix 'crash' -Manifest (New-SystemManifest -Kind 'CRASH REPORT' -Extra $extra)
80
80
  Write-UiLine '' 'Gray'
@@ -69,7 +69,7 @@ function Invoke-Section20 {
69
69
  foreach ($img in $images) { Write-UiLine (" {0,3} {1,10} {2}" -f $i, (Format-Bytes $img.Length), $img.FullName) 'Gray'; $i++ }
70
70
  if (-not (Test-CommandPresent 'diskpart.exe')) { Write-Warn 'diskpart.exe not found - skipped'; return }
71
71
  $picks = @()
72
- if ($ws.Yes) { $picks = @(1..$images.Count) } else { $picks = Read-MultiSelect -Total $images.Count }
72
+ if ($ws.Yes) { $picks = @(1..$images.Count) } else { $picks = @(Read-MultiSelect -Total $images.Count -NoAutoYes) }
73
73
  if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
74
74
  if (-not (Confirm-Section 'Stop Docker Desktop and WSL now and compact the selected disks?' 'n')) { Write-Info 'skipped'; return }
75
75
  if ($ws.DryRun) { foreach ($k in $picks) { Write-DryRun "would compact $($images[$k - 1].FullName)" }; return }
@@ -13,7 +13,8 @@ function Get-Targets06 {
13
13
  $t = @()
14
14
  foreach ($e in $Script:WS_EDITORS) {
15
15
  $root = Join-Path $P.A $e.Root
16
- $t += (New-Target 6 "$($e.Name) caches" $root -Kind editor -Guard $e.Proc -Note 'Cache, CachedData, Code Cache, GPUCache, VSIX cache, crash reports')
16
+ $t += (New-Target 6 "$($e.Name) caches" $root -Kind editor -Guard $e.Proc -Note 'Cache, CachedData, Code Cache, GPUCache, crash reports; skipped while the editor runs')
17
+ $t += (New-Target 6 "$($e.Name) VSIX download cache" (Join-Path $root 'CachedExtensionVSIXs') -Mode clear -Note 'extension installers already unpacked; safe while the editor runs')
17
18
  $t += (New-Target 6 "$($e.Name) logs" (Join-Path $root 'logs') -Mode clear-old -Days 7)
18
19
  $t += (New-Target 6 "$($e.Name) stale workspace storage" (Join-Path $root 'User\workspaceStorage') -Kind cmd -Guard $e.Proc -Note 'entries whose folder no longer exists')
19
20
  $t += (New-Target 6 "$($e.Name) superseded extension versions" (Join-Path $P.U "$($e.Ext)\extensions") -Kind cmd -Guard $e.Proc -Note 'older versions and .obsolete entries; newest kept')
@@ -0,0 +1,236 @@
1
+ # globals_audit.ps1 - section 22: globally installed packages, read-only.
2
+ # Lists what npm, pnpm, yarn, bun and deno installed globally, flags the ones nothing has referenced for
3
+ # N+ days, and prints the exact uninstall command. It NEVER removes anything, in any mode: several of these
4
+ # roots (nvm4w, AppData\npm, pnpm\global, .bun, .deno\bin) are protected subtrees by design.
5
+
6
+ # Never proposed as a candidate: the tool itself and the package managers everything else depends on.
7
+ $Script:WS_GLOBAL_KEEP = @('npm', 'corepack', 'pnpm', 'yarn', 'windowsweep')
8
+
9
+ function Get-GlobalPackageRoots {
10
+ <# .SYNOPSIS Existing global install roots, one row per manager. Style: node_modules (packages) | bin (shims). #>
11
+ $P = $Script:P
12
+ $rows = @()
13
+ $npmRoot = ''
14
+ if (Test-CommandPresent 'npm') {
15
+ try {
16
+ $out = & npm root -g 2>$null
17
+ $line = ($out | Select-Object -Last 1)
18
+ if ($line) { $npmRoot = $line.Trim() }
19
+ } catch { $null = $_ }
20
+ }
21
+ if (-not $npmRoot) { $npmRoot = Join-Path $P.A 'npm\node_modules' }
22
+ $fixed = @(
23
+ @('npm', $npmRoot, 'node_modules'),
24
+ @('yarn', "$($P.L)\Yarn\Data\global\node_modules", 'node_modules'),
25
+ @('bun', "$($P.U)\.bun\install\global\node_modules", 'node_modules'),
26
+ @('deno', "$($P.U)\.deno\bin", 'bin')
27
+ )
28
+ foreach ($f in $fixed) {
29
+ if (Test-DirPresent $f[1]) { $rows += [pscustomobject]@{ Manager = $f[0]; Root = (Get-FullPath $f[1]); Style = $f[2] } }
30
+ }
31
+ # Version-managed and store-style roots are globs: one root per installed Node version / pnpm version.
32
+ $globs = @()
33
+ $globs += @(@('npm', "$($P.A)\nvm\v*\node_modules"))
34
+ if ($env:NVM_HOME) { $globs += @(@('npm', (Join-Path $env:NVM_HOME 'v*\node_modules'))) }
35
+ $globs += @(@('pnpm', "$($P.L)\pnpm\global\*\node_modules"))
36
+ foreach ($g in $globs) {
37
+ $hits = @()
38
+ try { $hits = @(Get-Item -Path $g[1] -Force -ErrorAction SilentlyContinue | Where-Object { $_ -is [IO.DirectoryInfo] } | ForEach-Object { $_.FullName }) } catch { $hits = @() }
39
+ foreach ($h in $hits) { $rows += [pscustomobject]@{ Manager = $g[0]; Root = (Get-FullPath $h); Style = 'node_modules' } }
40
+ }
41
+ $seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
42
+ return @($rows | Where-Object { $seen.Add($_.Root) })
43
+ }
44
+
45
+ function Get-PackageBinNames {
46
+ <# .SYNOPSIS Command names a package.json 'bin' field installs: a string means one bin named after the package. #>
47
+ param([object] $Manifest, [string] $Name)
48
+ $out = @()
49
+ if ($null -eq $Manifest) { return $out }
50
+ $bin = $Manifest.bin
51
+ if ($null -eq $bin) { return $out }
52
+ if ($bin -is [string]) { return @(($Name -split '/')[-1]) }
53
+ foreach ($p in $bin.PSObject.Properties) { $out += $p.Name }
54
+ return $out
55
+ }
56
+
57
+ function Get-GlobalPackages {
58
+ <# .SYNOPSIS Packages under one global root. Reads each package.json (top level only) - never walks a global tree. #>
59
+ param([pscustomobject] $RootRow)
60
+ $rows = @()
61
+ if ($RootRow.Style -eq 'bin') {
62
+ foreach ($e in (Get-ChildEntries $RootRow.Root)) {
63
+ if ($e -is [IO.DirectoryInfo]) { continue }
64
+ if ($e.Extension -eq '.cmd') { continue }
65
+ $idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e)).TotalDays)
66
+ $rows += [pscustomobject]@{ Manager = $RootRow.Manager; Name = [IO.Path]::GetFileNameWithoutExtension($e.Name)
67
+ Version = ''; Path = (Remove-LongPrefix $e.FullName); Bytes = [long]$e.Length; Idle = $idle
68
+ Bins = @([IO.Path]::GetFileNameWithoutExtension($e.Name)) }
69
+ }
70
+ return $rows
71
+ }
72
+ $dirs = @()
73
+ foreach ($e in (Get-ChildEntries $RootRow.Root)) {
74
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
75
+ if ($e.Name -eq '.bin') { continue }
76
+ if ($e.Name.StartsWith('@')) {
77
+ foreach ($s in (Get-ChildEntries (Remove-LongPrefix $e.FullName))) {
78
+ if ($s -is [IO.DirectoryInfo]) { $dirs += [pscustomobject]@{ Name = ($e.Name + '/' + $s.Name); Dir = (Remove-LongPrefix $s.FullName) } }
79
+ }
80
+ continue
81
+ }
82
+ $dirs += [pscustomobject]@{ Name = $e.Name; Dir = (Remove-LongPrefix $e.FullName) }
83
+ }
84
+ foreach ($d in $dirs) {
85
+ $manifest = $null
86
+ $mf = Join-Path $d.Dir 'package.json'
87
+ if (Test-PathPresent $mf) { try { $manifest = @(Read-JsonFile $mf)[0] } catch { $manifest = $null } }
88
+ $version = ''
89
+ if ($manifest -and $manifest.version) { $version = [string]$manifest.version }
90
+ # The newest of the package's own top-level entries: a global node_modules is far too big to walk.
91
+ $newest = [datetime]::MinValue
92
+ foreach ($c in (Get-ChildEntries $d.Dir)) { $t = Get-NewestTimestampUtc $c; if ($t -gt $newest) { $newest = $t } }
93
+ if ($newest -eq [datetime]::MinValue) { $newest = [datetime]::UtcNow }
94
+ $rows += [pscustomobject]@{ Manager = $RootRow.Manager; Name = $d.Name; Version = $version; Path = $d.Dir
95
+ Bytes = [long](Get-DirectoryBytes $d.Dir); Idle = [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
96
+ Bins = @(Get-PackageBinNames -Manifest $manifest -Name $d.Name) }
97
+ }
98
+ return $rows
99
+ }
100
+
101
+ function Get-ProjectPackageIndex {
102
+ <# .SYNOPSIS What the project roots reference: dependency names, script text and local .bin command names.
103
+ Only manifests touched inside the idle window count - an ancient project does not keep a global alive. #>
104
+ param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 4, [int] $MaxFiles = 2000)
105
+ $names = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
106
+ $bins = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
107
+ $script = New-Object System.Text.StringBuilder
108
+ $seen = 0
109
+ foreach ($root in $Roots) {
110
+ $stack = New-Object System.Collections.Generic.Stack[object]
111
+ $stack.Push(@($root, 0))
112
+ while ($stack.Count -gt 0 -and $seen -lt $MaxFiles) {
113
+ $item = $stack.Pop()
114
+ $dir = [string]$item[0]; $depth = [int]$item[1]
115
+ foreach ($e in (Get-ChildEntries $dir)) {
116
+ if (Test-ReparsePoint $e) { continue }
117
+ if ($e -is [IO.DirectoryInfo]) {
118
+ if ($e.Name -eq 'node_modules') {
119
+ foreach ($b in (Get-ChildEntries (Join-Path (Remove-LongPrefix $e.FullName) '.bin'))) { $null = $bins.Add([IO.Path]::GetFileNameWithoutExtension($b.Name)) }
120
+ continue
121
+ }
122
+ if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
123
+ if ($depth -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), $depth + 1)) }
124
+ continue
125
+ }
126
+ if ($e.Name -ne 'package.json') { continue }
127
+ $seen++
128
+ if ([int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e)).TotalDays) -ge $Days) { continue }
129
+ $m = $null
130
+ try { $m = @(Read-JsonFile (Remove-LongPrefix $e.FullName))[0] } catch { $m = $null }
131
+ if ($null -eq $m) { continue }
132
+ foreach ($field in @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies')) {
133
+ if ($m.$field) { foreach ($p in $m.$field.PSObject.Properties) { $null = $names.Add($p.Name) } }
134
+ }
135
+ if ($m.scripts -and $script.Length -lt 200000) {
136
+ foreach ($p in $m.scripts.PSObject.Properties) { $null = $script.Append(' ').Append([string]$p.Value) }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ return [pscustomobject]@{ Names = $names; Bins = $bins; ScriptText = $script.ToString().ToLowerInvariant(); Scanned = $seen }
142
+ }
143
+
144
+ function Get-GlobalPackageVerdict {
145
+ <# .SYNOPSIS Pure verdict for one global package: keep | candidate, plus whether a project shadows its command.
146
+ Candidate needs all three: idle past the window, never referenced, and not a package manager we protect. #>
147
+ param([pscustomobject] $Package, [pscustomobject] $Index, [int] $Days)
148
+ $short = ($Package.Name -split '/')[-1]
149
+ $shadowed = $false
150
+ foreach ($b in @($Package.Bins)) { if ($Index.Bins.Contains($b)) { $shadowed = $true } }
151
+ if ($Script:WS_GLOBAL_KEEP -contains $short -or $Script:WS_GLOBAL_KEEP -contains $Package.Name) {
152
+ return [pscustomobject]@{ Verdict = 'keep'; Reason = 'package manager / this tool'; Shadowed = $shadowed }
153
+ }
154
+ if ($Index.Names.Contains($Package.Name)) { return [pscustomobject]@{ Verdict = 'keep'; Reason = 'a recent project depends on it'; Shadowed = $shadowed } }
155
+ foreach ($b in @($Package.Bins)) {
156
+ if ($b -and $Index.ScriptText.Contains($b.ToLowerInvariant())) { return [pscustomobject]@{ Verdict = 'keep'; Reason = "a recent project script runs '$b'"; Shadowed = $shadowed } }
157
+ }
158
+ if ($Package.Idle -lt $Days) { return [pscustomobject]@{ Verdict = 'keep'; Reason = "touched $($Package.Idle) days ago"; Shadowed = $shadowed } }
159
+ return [pscustomobject]@{ Verdict = 'candidate'; Reason = "nothing referenced it and it is $($Package.Idle) days old"; Shadowed = $shadowed }
160
+ }
161
+
162
+ function Get-GlobalUninstallCommand {
163
+ <# .SYNOPSIS The exact command that removes one global package for its manager. #>
164
+ param([string] $Manager, [string] $Name)
165
+ switch ($Manager) {
166
+ 'npm' { return "npm uninstall -g $Name" }
167
+ 'pnpm' { return "pnpm remove -g $Name" }
168
+ 'yarn' { return "yarn global remove $Name" }
169
+ 'bun' { return "bun remove -g $Name" }
170
+ 'deno' { return "deno uninstall $Name" }
171
+ default { return "# remove $Name manually" }
172
+ }
173
+ }
174
+
175
+ function Get-Targets22 {
176
+ <# .SYNOPSIS Informational rows only. Section 22 declares NO deletable target: these roots are protected. #>
177
+ $t = @()
178
+ foreach ($r in (Get-GlobalPackageRoots)) { $t += (New-Target 22 "$($r.Manager) global root" $r.Root -Kind cmd -Note 'read-only audit; this section never deletes') }
179
+ if ($t.Count -eq 0) { $t += (New-Target 22 'global package roots' '(none detected)' -Kind cmd -Note 'read-only audit; this section never deletes') }
180
+ return $t
181
+ }
182
+
183
+ function Invoke-Section22 {
184
+ $ws = $Script:WS
185
+ Write-SectionIntro @(
186
+ 'What npm, pnpm, yarn, bun and deno installed globally, and which of those nothing has referenced lately.',
187
+ "A package is a candidate only when it is $($ws.Days)+ days old AND no project touched recently names it.",
188
+ 'This section never uninstalls anything - it prints the command and leaves the decision to you.'
189
+ )
190
+ $roots = @(Get-GlobalPackageRoots)
191
+ if ($roots.Count -eq 0) { Write-Info 'no global package roots found'; return }
192
+ Write-Info ('roots: ' + (($roots | ForEach-Object { "$($_.Manager) -> $($_.Root)" }) -join ' '))
193
+ $packages = @()
194
+ foreach ($r in $roots) { $packages += @(Get-GlobalPackages -RootRow $r) }
195
+ if ($packages.Count -eq 0) { Write-Info 'no globally installed packages found'; return }
196
+ $projectRoots = @(Get-ProjectScanRoots)
197
+ $index = Get-ProjectPackageIndex -Roots $projectRoots -Days $ws.Days
198
+ Write-Note "checked $($index.Scanned) project manifest(s) under: $($projectRoots -join ' ')"
199
+ $lines = @("global packages audit - $(Get-Date -Format 'yyyy-MM-dd HH:mm') - idle window $($ws.Days) days", '')
200
+ $kept = @(); $cand = @()
201
+ foreach ($p in ($packages | Sort-Object Bytes -Descending)) {
202
+ $v = Get-GlobalPackageVerdict -Package $p -Index $index -Days $ws.Days
203
+ $row = [pscustomobject]@{ Package = $p; Verdict = $v }
204
+ if ($v.Verdict -eq 'candidate') { $cand += $row } else { $kept += $row }
205
+ }
206
+ Write-Section 'Kept'
207
+ Write-UiLine (" {0,-34} {1,-10} {2,10} {3,6} {4}" -f 'PACKAGE', 'VERSION', 'SIZE', 'IDLE', 'WHY') 'White'
208
+ foreach ($r in $kept) {
209
+ $line = " {0,-34} {1,-10} {2,10} {3,5}d {4}" -f $r.Package.Name, $r.Package.Version, (Format-Bytes $r.Package.Bytes), $r.Package.Idle, $r.Verdict.Reason
210
+ Write-UiLine $line 'Gray'; $lines += ('kept ' + $line.Trim())
211
+ }
212
+ Write-Section 'Candidates - nothing recent referenced these'
213
+ if ($cand.Count -eq 0) { Write-Info 'none' } else {
214
+ Write-UiLine (" {0,-34} {1,-10} {2,10} {3,6} {4}" -f 'PACKAGE', 'VERSION', 'SIZE', 'IDLE', 'NOTE') 'White'
215
+ $total = [long]0
216
+ foreach ($r in $cand) {
217
+ $note = ''
218
+ if ($r.Verdict.Shadowed) { $note = 'a project installs this command locally' }
219
+ $line = " {0,-34} {1,-10} {2,10} {3,5}d {4}" -f $r.Package.Name, $r.Package.Version, (Format-Bytes $r.Package.Bytes), $r.Package.Idle, $note
220
+ Write-UiLine $line 'Yellow'; $lines += ('candidate ' + $line.Trim())
221
+ $total += $r.Package.Bytes
222
+ }
223
+ Write-Info ('candidates hold ' + (Format-Bytes $total))
224
+ Write-Section 'Remove them yourself with'
225
+ $lines += ''; $lines += 'uninstall commands:'
226
+ foreach ($r in $cand) {
227
+ $cmd = Get-GlobalUninstallCommand -Manager $r.Package.Manager -Name $r.Package.Name
228
+ Write-Plain " $cmd"; $lines += " $cmd"
229
+ }
230
+ Write-Note 'windowsweep does not run these: a global package can be a command you rely on rarely.'
231
+ }
232
+ if (-not $ws.NoReport) {
233
+ $out = Join-Path $ws.ReportsDir ('global-packages-' + $ws.Stamp + '.txt')
234
+ try { [IO.File]::WriteAllLines($out, $lines); Write-Note "list saved: $out" } catch { $null = $_ }
235
+ }
236
+ }
@@ -76,7 +76,7 @@ function Invoke-Section00 {
76
76
  } catch { $null = $_ }
77
77
  }
78
78
  Write-UiLine '' 'Gray'
79
- try { $n = @(Get-CimInstance Win32_StartupCommand -ErrorAction Stop).Count; Write-Kv 'Startup items:' "$n (review in Task Manager > Startup; this tool does not change them)" } catch { $null = $_ }
79
+ try { $n = @(Get-CimInstance Win32_StartupCommand -ErrorAction Stop).Count; Write-Kv 'Startup items:' "$n (section 25 lists them all; change them in Task Manager > Startup - this tool never does)" } catch { $null = $_ }
80
80
  try {
81
81
  $ss = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\StorageSense\Parameters\StoragePolicy' -ErrorAction Stop).'01'
82
82
  $ssText = 'off'
@@ -0,0 +1,118 @@
1
+ # installed_programs.ps1 - section 24: installed programs nothing has modified for N+ days. Report only.
2
+ #
3
+ # It NEVER runs an uninstaller, in any mode. Windows keeps no reliable "last launched" record, so the number
4
+ # reported is "not modified", derived from the files under InstallLocation - never described as "unused".
5
+
6
+ function Get-WingetIdMap {
7
+ <# .SYNOPSIS One winget call, parsed once: normalised program name -> package id. Empty when winget is absent. #>
8
+ $map = @{}
9
+ if (-not (Test-CommandPresent 'winget')) { return $map }
10
+ $lines = @()
11
+ try { $lines = @(& winget list --disable-interactivity 2>$null) } catch { return $map }
12
+ foreach ($line in $lines) {
13
+ $text = [string]$line
14
+ # "Name Id Version Source" with runs of spaces between columns; ids never contain a space.
15
+ if ($text -notmatch '^(.+?)\s\s+([^\s]+)\s\s+') { continue }
16
+ $name = ConvertTo-MatchToken $Matches[1]
17
+ $id = $Matches[2]
18
+ if ($name.Length -lt 3 -or $id -eq 'Id') { continue }
19
+ if (-not $map.ContainsKey($name)) { $map[$name] = $id }
20
+ }
21
+ return $map
22
+ }
23
+
24
+ function Get-InstallLocationIdleDays {
25
+ <# .SYNOPSIS Days since the newest LAST-WRITE at depth <= 2 under an install folder, or -1 when unknowable.
26
+ Deliberately NOT Get-NewestTimestampUtc, which the cleanup sections use: that takes the newest of write,
27
+ access and creation, and last-access is live on this machine - measured 2026-09-04, every install folder
28
+ read as 0 days idle that way (Git, Gpg4win, RustDesk, Malwarebytes all 0d) while last-write gave 120d,
29
+ 203d, 163d and 148d. This section asks "not modified", so last-write is the matching question. #>
30
+ param([string] $Location, [int] $MaxEntries = 4000)
31
+ if ([string]::IsNullOrWhiteSpace($Location)) { return -1 }
32
+ $root = Get-FullPath ($Location.Trim().Trim('"').TrimEnd('\'))
33
+ if (-not $root -or -not (Test-DirPresent $root)) { return -1 }
34
+ $newest = [datetime]::MinValue
35
+ $seen = 0
36
+ $stack = New-Object System.Collections.Generic.Stack[object]
37
+ $stack.Push(@($root, 0))
38
+ while ($stack.Count -gt 0 -and $seen -lt $MaxEntries) {
39
+ $item = $stack.Pop()
40
+ foreach ($e in (Get-ChildEntries ([string]$item[0]))) {
41
+ $seen++
42
+ if (Test-ReparsePoint $e) { continue }
43
+ if ($e -is [IO.DirectoryInfo]) {
44
+ if ([int]$item[1] -lt 2) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }
45
+ continue
46
+ }
47
+ if ($e.LastWriteTimeUtc -gt $newest) { $newest = $e.LastWriteTimeUtc }
48
+ }
49
+ }
50
+ if ($newest -eq [datetime]::MinValue) { return -1 }
51
+ return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
52
+ }
53
+
54
+ function Get-ProgramRemovalHint {
55
+ <# .SYNOPSIS The command that removes a program: winget when it knows it, otherwise its own UninstallString. #>
56
+ param([pscustomobject] $Row, [hashtable] $WingetMap)
57
+ $key = ConvertTo-MatchToken $Row.DisplayName
58
+ if ($WingetMap.ContainsKey($key)) { return "winget uninstall --id $($WingetMap[$key])" }
59
+ if ($Row.UninstallString) { return $Row.UninstallString }
60
+ return '(no uninstall command recorded - remove it from Settings > Apps)'
61
+ }
62
+
63
+ function Get-Targets24 {
64
+ <# .SYNOPSIS Informational only. Section 24 declares nothing deletable: Program Files is protected. #>
65
+ return @((New-Target 24 'Installed programs (registry)' 'HKLM + HKCU Uninstall hives' -Kind cmd -Note 'report only; this section never runs an uninstaller'))
66
+ }
67
+
68
+ function Invoke-Section24 {
69
+ $ws = $Script:WS
70
+ Write-SectionIntro @(
71
+ 'Programs whose files nothing has modified for a long time, largest first, with the command that removes',
72
+ 'each one. Windows keeps no reliable record of when a program last RAN, so this is "not modified", not',
73
+ '"not used" - check the list before acting. This section never runs an uninstaller.'
74
+ )
75
+ $rows = @(Get-InstalledProgramRows | Where-Object { $_.SystemComponent -ne 1 })
76
+ if ($rows.Count -eq 0) { Write-Warn 'no uninstall entries could be read'; return }
77
+ Write-Note "$($rows.Count) installed program(s) found; measuring the ones with an install folder"
78
+ $winget = Get-WingetIdMap
79
+ $scored = @()
80
+ foreach ($r in $rows) {
81
+ $idle = Get-InstallLocationIdleDays -Location $r.InstallLocation
82
+ if ($idle -lt $ws.Days) { continue }
83
+ $scored += [pscustomobject]@{ Row = $r; Idle = $idle; Bytes = ([long]$r.EstimatedSizeKb * 1024) }
84
+ }
85
+ $lines = @("installed programs not modified for $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
86
+ if ($scored.Count -eq 0) {
87
+ Write-Info "no installed program has been untouched for $($ws.Days)+ days"
88
+ } else {
89
+ Write-UiLine (" {0,-44} {1,-14} {2,10} {3,6} {4}" -f 'PROGRAM', 'VERSION', 'SIZE', 'IDLE', 'PUBLISHER') 'White'
90
+ foreach ($s in ($scored | Sort-Object Bytes -Descending)) {
91
+ $size = 'unknown'
92
+ if ($s.Bytes -gt 0) { $size = Format-Bytes $s.Bytes }
93
+ $line = " {0,-44} {1,-14} {2,10} {3,5}d {4}" -f $s.Row.DisplayName, $s.Row.DisplayVersion, $size, $s.Idle, $s.Row.Publisher
94
+ Write-UiLine $line 'Gray'
95
+ $lines += $line.Trim()
96
+ $lines += (' remove with: ' + (Get-ProgramRemovalHint -Row $s.Row -WingetMap $winget))
97
+ }
98
+ Write-Section 'Remove one yourself with'
99
+ foreach ($s in ($scored | Sort-Object Bytes -Descending | Select-Object -First 10)) {
100
+ Write-Plain (' ' + (Get-ProgramRemovalHint -Row $s.Row -WingetMap $winget))
101
+ }
102
+ }
103
+ Write-Section 'Store apps (not system packages)'
104
+ $appx = @()
105
+ try { $appx = @(Get-AppxPackage -ErrorAction SilentlyContinue | Where-Object { -not $_.IsFramework -and $_.SignatureKind -ne 'System' }) } catch { $appx = @() }
106
+ if ($appx.Count -eq 0) { Write-Info 'none found' } else {
107
+ foreach ($a in ($appx | Sort-Object Name)) {
108
+ Write-UiLine (" {0,-52} {1}" -f $a.Name, $a.Version) 'Gray'
109
+ $lines += ("store {0} {1}" -f $a.Name, $a.Version)
110
+ $lines += (' remove with: Remove-AppxPackage -Package ' + $a.PackageFullName)
111
+ }
112
+ Write-Note 'remove one with: Remove-AppxPackage -Package <PackageFullName>'
113
+ }
114
+ if (-not $ws.NoReport) {
115
+ $out = Join-Path $ws.ReportsDir ('installed-programs-' + $ws.Stamp + '.txt')
116
+ try { [IO.File]::WriteAllLines($out, $lines); Write-Note "list saved: $out" } catch { $null = $_ }
117
+ }
118
+ }
package/modules/menu.ps1 CHANGED
@@ -37,7 +37,9 @@ function Invoke-Menu {
37
37
  }
38
38
  while ($true) {
39
39
  Show-Menu
40
- $choice = Read-Choice -Prompt ' Select [0-21 / A / D / Y / S / Q]: ' -Default ''
40
+ $lo = ($Script:WS_SECTIONS | Measure-Object Id -Minimum).Minimum
41
+ $hi = ($Script:WS_SECTIONS | Measure-Object Id -Maximum).Maximum
42
+ $choice = Read-Choice -Prompt " Select [$lo-$hi / A / D / Y / S / Q]: " -Default ''
41
43
  if ($choice -eq '') { continue }
42
44
  switch -Regex ($choice) {
43
45
  '^q' { Show-SessionSummary; return }
@@ -0,0 +1,60 @@
1
+ # notify.ps1 - --notify: one Windows notification when a run ends.
2
+ #
3
+ # Off by default. --quiet still notifies (that is the point of the flag when a Scheduled Task runs it).
4
+ # Every path is wrapped: a notification that cannot be shown is not a failed run, so this NEVER touches the
5
+ # exit code and NEVER writes to stdout - --json promises exactly one line there.
6
+
7
+ function Get-RunNotificationText {
8
+ <# .SYNOPSIS The two lines a notification carries. Pure, so the self-test can read it without a toast. #>
9
+ $ws = $Script:WS
10
+ $ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
11
+ if ($ws.DryRun) {
12
+ return [pscustomobject]@{ Title = "$Script:WS_NAME - dry run finished"; Body = ("about " + (Format-Bytes $ws.TotalEstimated) + " could be freed across $ran section(s)") }
13
+ }
14
+ return [pscustomobject]@{ Title = "$Script:WS_NAME finished"; Body = ("freed " + (Format-Bytes $ws.TotalFreed) + " across $ran section(s)") }
15
+ }
16
+
17
+ function Show-ToastNotification {
18
+ <# .SYNOPSIS Windows PowerShell 5.1: a real toast through WinRT, under the PowerShell AppUserModelId. #>
19
+ param([string] $Title, [string] $Body)
20
+ $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
21
+ $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
22
+ $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
23
+ $tpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
24
+ $texts = $tpl.GetElementsByTagName('text')
25
+ $null = $texts.Item(0).AppendChild($tpl.CreateTextNode($Title))
26
+ $null = $texts.Item(1).AppendChild($tpl.CreateTextNode($Body))
27
+ $toast = New-Object Windows.UI.Notifications.ToastNotification($tpl)
28
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
29
+ }
30
+
31
+ function Show-BalloonNotification {
32
+ <# .SYNOPSIS PowerShell 7 has no WinRT projection by default: a tray balloon instead. #>
33
+ param([string] $Title, [string] $Body)
34
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
35
+ Add-Type -AssemblyName System.Drawing -ErrorAction Stop
36
+ $icon = New-Object System.Windows.Forms.NotifyIcon
37
+ try {
38
+ $icon.Icon = [System.Drawing.SystemIcons]::Information
39
+ $icon.Visible = $true
40
+ $icon.ShowBalloonTip(5000, $Title, $Body, [System.Windows.Forms.ToolTipIcon]::Info)
41
+ # The balloon needs the tray icon to survive a moment; without this it is created and destroyed unseen.
42
+ Start-Sleep -Milliseconds 600
43
+ } finally {
44
+ $icon.Visible = $false
45
+ $icon.Dispose()
46
+ }
47
+ }
48
+
49
+ function Send-RunNotification {
50
+ <# .SYNOPSIS --notify: show the end-of-run notification, or say quietly in the log why it could not. #>
51
+ if (-not $Script:WS.Notify) { return }
52
+ $text = Get-RunNotificationText
53
+ try {
54
+ if ($PSVersionTable.PSEdition -eq 'Core') { Show-BalloonNotification -Title $text.Title -Body $text.Body }
55
+ else { Show-ToastNotification -Title $text.Title -Body $text.Body }
56
+ Write-LogLine "notification shown: $($text.Title) - $($text.Body)"
57
+ } catch {
58
+ Write-LogLine "notification could not be shown (this never affects the exit code): $($_.Exception.Message)"
59
+ }
60
+ }