windowsweep 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AI-INTEGRATION-GUIDE.md +16 -12
  2. package/CHANGELOG.md +82 -0
  3. package/LICENSE +21 -21
  4. package/README.md +104 -69
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -212
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -93
  9. package/lib/external.ps1 +87 -0
  10. package/lib/fs.ps1 +189 -180
  11. package/lib/log.ps1 +140 -140
  12. package/lib/safety.ps1 +467 -493
  13. package/lib/scan.ps1 +109 -100
  14. package/lib/ui.ps1 +275 -275
  15. package/modules/android_avd.ps1 +36 -36
  16. package/modules/app_caches.ps1 +58 -58
  17. package/modules/browsers.ps1 +33 -33
  18. package/modules/build_tools.ps1 +35 -35
  19. package/modules/crash_trap.ps1 +89 -89
  20. package/modules/disk_usage.ps1 +49 -49
  21. package/modules/docker.ps1 +93 -93
  22. package/modules/editors.ps1 +150 -150
  23. package/modules/globals_audit.ps1 +236 -236
  24. package/modules/health.ps1 +101 -101
  25. package/modules/installed_programs.ps1 +118 -118
  26. package/modules/menu.ps1 +63 -63
  27. package/modules/notify.ps1 +60 -60
  28. package/modules/orphaned_appdata.ps1 +189 -189
  29. package/modules/personal.ps1 +126 -126
  30. package/modules/pkg_managers.ps1 +73 -73
  31. package/modules/projects.ps1 +160 -160
  32. package/modules/release_helpers.ps1 +438 -438
  33. package/modules/reports.ps1 +212 -212
  34. package/modules/runner.ps1 +246 -239
  35. package/modules/self_test_contract.ps1 +195 -0
  36. package/modules/self_test_extra.ps1 +366 -360
  37. package/modules/startup_audit.ps1 +126 -126
  38. package/modules/system_admin.ps1 +220 -220
  39. package/modules/test_runners.ps1 +24 -24
  40. package/modules/walkthrough.ps1 +80 -79
  41. package/modules/windows_user.ps1 +118 -118
  42. package/package.json +3 -3
  43. package/windowsweep.ps1 +322 -316
@@ -1,236 +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
- }
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
+ }