windowsweep 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI-INTEGRATION-GUIDE.md +15 -11
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -21
- package/README.md +70 -42
- package/VERSION +1 -1
- package/lib/actions.ps1 +212 -212
- package/lib/config.ps1 +108 -99
- package/lib/constants.ps1 +103 -93
- package/lib/fs.ps1 +189 -180
- package/lib/log.ps1 +140 -140
- package/lib/safety.ps1 +541 -493
- package/lib/scan.ps1 +109 -100
- package/lib/ui.ps1 +275 -275
- package/modules/android_avd.ps1 +36 -36
- package/modules/app_caches.ps1 +58 -58
- package/modules/browsers.ps1 +33 -33
- package/modules/build_tools.ps1 +35 -35
- package/modules/crash_trap.ps1 +89 -89
- package/modules/disk_usage.ps1 +49 -49
- package/modules/docker.ps1 +93 -93
- package/modules/editors.ps1 +150 -150
- package/modules/globals_audit.ps1 +236 -236
- package/modules/health.ps1 +101 -101
- package/modules/installed_programs.ps1 +118 -118
- package/modules/menu.ps1 +63 -63
- package/modules/notify.ps1 +60 -60
- package/modules/orphaned_appdata.ps1 +189 -189
- package/modules/personal.ps1 +126 -126
- package/modules/pkg_managers.ps1 +73 -73
- package/modules/projects.ps1 +160 -160
- package/modules/release_helpers.ps1 +438 -438
- package/modules/reports.ps1 +212 -212
- package/modules/runner.ps1 +242 -239
- package/modules/self_test_extra.ps1 +537 -360
- package/modules/startup_audit.ps1 +126 -126
- package/modules/system_admin.ps1 +220 -220
- package/modules/test_runners.ps1 +24 -24
- package/modules/walkthrough.ps1 +80 -79
- package/modules/windows_user.ps1 +118 -118
- package/package.json +2 -2
- package/windowsweep.ps1 +322 -316
|
@@ -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
|
+
}
|