windowsweep 1.0.1 → 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.
Files changed (41) hide show
  1. package/AI-INTEGRATION-GUIDE.md +203 -0
  2. package/CHANGELOG.md +124 -0
  3. package/LICENSE +21 -21
  4. package/README.md +88 -49
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -209
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -89
  9. package/lib/fs.ps1 +189 -180
  10. package/lib/log.ps1 +140 -140
  11. package/lib/safety.ps1 +541 -493
  12. package/lib/scan.ps1 +109 -94
  13. package/lib/ui.ps1 +275 -235
  14. package/modules/android_avd.ps1 +36 -36
  15. package/modules/app_caches.ps1 +58 -58
  16. package/modules/browsers.ps1 +33 -33
  17. package/modules/build_tools.ps1 +35 -35
  18. package/modules/crash_trap.ps1 +89 -89
  19. package/modules/disk_usage.ps1 +49 -49
  20. package/modules/docker.ps1 +93 -93
  21. package/modules/editors.ps1 +150 -150
  22. package/modules/globals_audit.ps1 +236 -0
  23. package/modules/health.ps1 +101 -101
  24. package/modules/installed_programs.ps1 +118 -0
  25. package/modules/menu.ps1 +63 -61
  26. package/modules/notify.ps1 +60 -0
  27. package/modules/orphaned_appdata.ps1 +189 -0
  28. package/modules/personal.ps1 +126 -124
  29. package/modules/pkg_managers.ps1 +73 -69
  30. package/modules/projects.ps1 +160 -143
  31. package/modules/release_helpers.ps1 +438 -434
  32. package/modules/reports.ps1 +212 -212
  33. package/modules/runner.ps1 +242 -190
  34. package/modules/self_test_extra.ps1 +537 -86
  35. package/modules/startup_audit.ps1 +126 -0
  36. package/modules/system_admin.ps1 +220 -220
  37. package/modules/test_runners.ps1 +24 -24
  38. package/modules/walkthrough.ps1 +80 -79
  39. package/modules/windows_user.ps1 +118 -112
  40. package/package.json +4 -3
  41. package/windowsweep.ps1 +322 -296
@@ -1,69 +1,73 @@
1
- # pkg_managers.ps1 - section 1: package-manager caches. All regenerable; the idle gate keeps recent work fast.
2
-
3
- function Get-Targets01 {
4
- $P = $Script:P
5
- $npm = Get-NpmCacheDir
6
- return @(
7
- (New-Target 1 'npm cache (_cacache)' (Join-Path $npm '_cacache') -Mode prune -Dev $true -Note 'regenerated on the next npm install')
8
- (New-Target 1 'npx package cache (_npx)' (Join-Path $npm '_npx') -Mode prune -Dev $true)
9
- (New-Target 1 'npm logs' (Join-Path $npm '_logs') -Mode clear-old -Days 7)
10
- (New-Target 1 'Yarn v1 cache' "$($P.L)\Yarn\Cache" -Mode prune -Dev $true -Note 'yarn classic offline mirror')
11
- (New-Target 1 'Yarn Berry global cache' "$($P.L)\Yarn\Berry\cache" -Mode prune -Dev $true)
12
- (New-Target 1 'pnpm content store' "$($P.L)\pnpm\store" -Mode prune -Dev $true -Note 'pnpm store prune is used instead when pnpm is installed')
13
- (New-Target 1 'pnpm metadata cache' "$($P.L)\pnpm-cache" -Mode prune -Dev $true)
14
- (New-Target 1 'bun install cache' "$($P.U)\.bun\install\cache" -Mode prune -Dev $true -Note 'global bun packages stay')
15
- (New-Target 1 'deno deps cache' "$($P.L)\deno\deps" -Mode prune -Dev $true -Note 'installed deno scripts (~\.deno\bin) stay')
16
- (New-Target 1 'deno gen cache' "$($P.L)\deno\gen" -Mode prune -Dev $true)
17
- (New-Target 1 'deno npm cache' "$($P.L)\deno\npm" -Mode prune -Dev $true)
18
- (New-Target 1 'deno remote cache' "$($P.L)\deno\remote" -Mode prune -Dev $true)
19
- (New-Target 1 'pip cache' "$($P.L)\pip\cache" -Mode prune -Dev $true)
20
- (New-Target 1 'uv cache' "$($P.L)\uv\cache" -Mode prune -Dev $true -Note 'uv cache prune runs first when uv is installed')
21
- (New-Target 1 'poetry cache' "$($P.L)\pypoetry\Cache" -Mode prune -Dev $true)
22
- (New-Target 1 'Composer cache (files)' "$($P.L)\Composer\files" -Mode prune -Dev $true)
23
- (New-Target 1 'Composer cache (roaming)' "$($P.A)\Composer\cache" -Mode prune -Dev $true)
24
- (New-Target 1 'NuGet http cache' "$($P.L)\NuGet\v3-cache" -Mode clear)
25
- (New-Target 1 'NuGet plugins cache' "$($P.L)\NuGet\plugins-cache" -Mode clear)
26
- (New-Target 1 'NuGet scratch' "$($P.L)\Temp\NuGetScratch" -Mode clear)
27
- (New-Target 1 'NuGet global packages' "$($P.U)\.nuget\packages" -Mode prune -Dev $true -Note 'restored on the next dotnet restore')
28
- (New-Target 1 'Cargo registry cache' "$($P.U)\.cargo\registry\cache" -Mode prune -Dev $true -Note 'installed cargo binaries (~\.cargo\bin) stay')
29
- (New-Target 1 'Cargo registry sources' "$($P.U)\.cargo\registry\src" -Mode prune -Dev $true)
30
- (New-Target 1 'Cargo git checkouts' "$($P.U)\.cargo\git\checkouts" -Mode prune -Dev $true)
31
- (New-Target 1 'Go build cache' "$($P.L)\go-build" -Mode prune -Dev $true)
32
- (New-Target 1 'Go module download cache' "$($P.U)\go\pkg\mod\cache\download" -Mode prune -Dev $true)
33
- (New-Target 1 'Dart/Flutter pub cache' "$($P.L)\Pub\Cache" -Mode prune -Dev $true -Note 'redownloaded by flutter pub get')
34
- (New-Target 1 'Dart/Flutter pub cache (legacy)' "$($P.A)\Pub\Cache" -Mode prune -Dev $true)
35
- (New-Target 1 'Dart analysis server cache' "$($P.U)\.dartServer" -Mode prune -Dev $true)
36
- (New-Target 1 'Electron download cache' "$($P.L)\electron\Cache" -Mode prune -Dev $true)
37
- (New-Target 1 'electron-builder cache' "$($P.L)\electron-builder\Cache" -Mode prune -Dev $true)
38
- (New-Target 1 'node-gyp headers cache' "$($P.L)\node-gyp\Cache" -Mode prune -Dev $true)
39
- (New-Target 1 'Scoop installer cache' "$($P.U)\scoop\cache" -Mode prune -Dev $true)
40
- )
41
- }
42
-
43
- function Invoke-Section01 {
44
- $ws = $Script:WS
45
- Write-SectionIntro @(
46
- 'Package-manager caches only re-download what a future install needs. Globally installed packages,',
47
- 'toolchain managers (nvm, Volta, corepack, pnpm global, cargo bin, go bin) are protected and never touched.'
48
- ) -Dev $true
49
- $targets = Get-Targets01
50
- $null = Show-TargetSizes $targets
51
- if (-not (Confirm-Section 'Prune package-manager caches now?')) { Write-Info 'skipped'; return }
52
- # Tool-native smart prunes first: they know which entries are still referenced.
53
- if ((Test-CommandPresent 'pnpm') -and $ws.Developer -ne $false -and -not $ws.PurgeAll) {
54
- Write-Info 'pnpm store prune - removes packages no project references'
55
- $r = Invoke-External -FilePath 'pnpm' -ArgumentList @('store', 'prune') -Destructive -Quiet -Label 'pnpm store prune'
56
- if ($r.Ran -and $r.ExitCode -eq 0) { Write-Ok 'pnpm store pruned' }
57
- $targets = @($targets | Where-Object { $_.Label -ne 'pnpm content store' })
58
- }
59
- if ((Test-CommandPresent 'uv') -and $ws.Developer -ne $false -and -not $ws.PurgeAll) {
60
- Write-Info 'uv cache prune - removes unreachable wheels and sources'
61
- $r = Invoke-External -FilePath 'uv' -ArgumentList @('cache', 'prune') -Destructive -Quiet -Label 'uv cache prune'
62
- if ($r.Ran -and $r.ExitCode -eq 0) { Write-Ok 'uv cache pruned' }
63
- }
64
- if ((Test-CommandPresent 'npm') -and $ws.PurgeAll) {
65
- Write-Info 'npm cache clean --force'
66
- $null = Invoke-External -FilePath 'npm' -ArgumentList @('cache', 'clean', '--force') -Destructive -Quiet -Label 'npm cache clean'
67
- }
68
- $null = Invoke-TargetList $targets
69
- }
1
+ # pkg_managers.ps1 - section 1: package-manager caches. All regenerable; the idle gate keeps recent work fast.
2
+
3
+ function Get-Targets01 {
4
+ $P = $Script:P
5
+ $npm = Get-NpmCacheDir
6
+ return @(
7
+ (New-Target 1 'npm cache (_cacache)' (Join-Path $npm '_cacache') -Mode prune -Dev $true -Note 'regenerated on the next npm install')
8
+ (New-Target 1 'npx package cache (_npx)' (Join-Path $npm '_npx') -Mode prune -Dev $true)
9
+ (New-Target 1 'npm logs' (Join-Path $npm '_logs') -Mode clear-old -Days 7)
10
+ (New-Target 1 'Yarn v1 cache' "$($P.L)\Yarn\Cache" -Mode prune -Dev $true -Note 'yarn classic offline mirror')
11
+ (New-Target 1 'Yarn Berry global cache' "$($P.L)\Yarn\Berry\cache" -Mode prune -Dev $true)
12
+ (New-Target 1 'pnpm content store' "$($P.L)\pnpm\store" -Mode prune -Dev $true -Note 'pnpm store prune is used instead when pnpm is installed')
13
+ (New-Target 1 'pnpm metadata cache' "$($P.L)\pnpm-cache" -Mode prune -Dev $true)
14
+ (New-Target 1 'bun install cache' "$($P.U)\.bun\install\cache" -Mode prune -Dev $true -Note 'global bun packages stay')
15
+ (New-Target 1 'deno deps cache' "$($P.L)\deno\deps" -Mode prune -Dev $true -Note 'installed deno scripts (~\.deno\bin) stay')
16
+ (New-Target 1 'deno gen cache' "$($P.L)\deno\gen" -Mode prune -Dev $true)
17
+ (New-Target 1 'deno npm cache' "$($P.L)\deno\npm" -Mode prune -Dev $true)
18
+ (New-Target 1 'deno remote cache' "$($P.L)\deno\remote" -Mode prune -Dev $true)
19
+ (New-Target 1 'pip cache' "$($P.L)\pip\cache" -Mode prune -Dev $true)
20
+ (New-Target 1 'uv cache' "$($P.L)\uv\cache" -Mode prune -Dev $true -Note 'uv cache prune runs first when uv is installed')
21
+ (New-Target 1 'poetry cache' "$($P.L)\pypoetry\Cache" -Mode prune -Dev $true)
22
+ (New-Target 1 'Composer cache (files)' "$($P.L)\Composer\files" -Mode prune -Dev $true)
23
+ (New-Target 1 'Composer cache (roaming)' "$($P.A)\Composer\cache" -Mode prune -Dev $true)
24
+ (New-Target 1 'NuGet http cache' "$($P.L)\NuGet\v3-cache" -Mode clear)
25
+ (New-Target 1 'NuGet plugins cache' "$($P.L)\NuGet\plugins-cache" -Mode clear)
26
+ (New-Target 1 'NuGet scratch' "$($P.L)\Temp\NuGetScratch" -Mode clear)
27
+ (New-Target 1 'NuGet global packages' "$($P.U)\.nuget\packages" -Mode prune -Dev $true -Note 'restored on the next dotnet restore')
28
+ (New-Target 1 'Cargo registry cache' "$($P.U)\.cargo\registry\cache" -Mode prune -Dev $true -Note 'installed cargo binaries (~\.cargo\bin) stay')
29
+ (New-Target 1 'Cargo registry sources' "$($P.U)\.cargo\registry\src" -Mode prune -Dev $true)
30
+ (New-Target 1 'Cargo git checkouts' "$($P.U)\.cargo\git\checkouts" -Mode prune -Dev $true)
31
+ (New-Target 1 'Go build cache' "$($P.L)\go-build" -Mode prune -Dev $true)
32
+ (New-Target 1 'Go module download cache' "$($P.U)\go\pkg\mod\cache\download" -Mode prune -Dev $true)
33
+ (New-Target 1 'Dart/Flutter pub cache' "$($P.L)\Pub\Cache" -Mode prune -Dev $true -Note 'redownloaded by flutter pub get')
34
+ (New-Target 1 'Dart/Flutter pub cache (legacy)' "$($P.A)\Pub\Cache" -Mode prune -Dev $true)
35
+ (New-Target 1 'Dart analysis server cache' "$($P.U)\.dartServer" -Mode prune -Dev $true)
36
+ (New-Target 1 'Electron download cache' "$($P.L)\electron\Cache" -Mode prune -Dev $true)
37
+ (New-Target 1 'electron-builder cache' "$($P.L)\electron-builder\Cache" -Mode prune -Dev $true)
38
+ (New-Target 1 'node-gyp headers cache' "$($P.L)\node-gyp\Cache" -Mode prune -Dev $true)
39
+ (New-Target 1 'Scoop installer cache' "$($P.U)\scoop\cache" -Mode prune -Dev $true)
40
+ # Hugging Face keeps revisions as snapshots\<rev> linking into blobs, so removing a whole snapshot orphans
41
+ # blobs: this prunes by the idle gate instead of keeping "the newest per model". AI agent state under
42
+ # ~\.cache (claude, codex, gemini, copilot) stays protected by lib/safety.ps1.
43
+ (New-Target 1 'Hugging Face model cache' "$($P.U)\.cache\huggingface\hub" -Mode prune -Dev $true -Note 'models are re-downloaded on next use; the idle gate keeps recent ones')
44
+ )
45
+ }
46
+
47
+ function Invoke-Section01 {
48
+ $ws = $Script:WS
49
+ Write-SectionIntro @(
50
+ 'Package-manager caches only re-download what a future install needs. Globally installed packages,',
51
+ 'toolchain managers (nvm, Volta, corepack, pnpm global, cargo bin, go bin) are protected and never touched.'
52
+ ) -Dev $true
53
+ $targets = Get-Targets01
54
+ $null = Show-TargetSizes $targets
55
+ if (-not (Confirm-Section 'Prune package-manager caches now?')) { Write-Info 'skipped'; return }
56
+ # Tool-native smart prunes first: they know which entries are still referenced.
57
+ if ((Test-CommandPresent 'pnpm') -and $ws.Developer -ne $false -and -not $ws.PurgeAll) {
58
+ Write-Info 'pnpm store prune - removes packages no project references'
59
+ $r = Invoke-External -FilePath 'pnpm' -ArgumentList @('store', 'prune') -Destructive -Quiet -Label 'pnpm store prune'
60
+ if ($r.Ran -and $r.ExitCode -eq 0) { Write-Ok 'pnpm store pruned' }
61
+ $targets = @($targets | Where-Object { $_.Label -ne 'pnpm content store' })
62
+ }
63
+ if ((Test-CommandPresent 'uv') -and $ws.Developer -ne $false -and -not $ws.PurgeAll) {
64
+ Write-Info 'uv cache prune - removes unreachable wheels and sources'
65
+ $r = Invoke-External -FilePath 'uv' -ArgumentList @('cache', 'prune') -Destructive -Quiet -Label 'uv cache prune'
66
+ if ($r.Ran -and $r.ExitCode -eq 0) { Write-Ok 'uv cache pruned' }
67
+ }
68
+ if ((Test-CommandPresent 'npm') -and $ws.PurgeAll) {
69
+ Write-Info 'npm cache clean --force'
70
+ $null = Invoke-External -FilePath 'npm' -ArgumentList @('cache', 'clean', '--force') -Destructive -Quiet -Label 'npm cache clean'
71
+ }
72
+ $null = Invoke-TargetList $targets
73
+ }
@@ -1,143 +1,160 @@
1
- # projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
2
-
3
- $Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular')
4
- $Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
5
- $Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
6
-
7
- function Get-ProjectScanRoots {
8
- $ws = $Script:WS
9
- $U = $Script:P.U
10
- $roots = @()
11
- if ($ws.ScanRoots.Count -gt 0) {
12
- foreach ($r in $ws.ScanRoots) {
13
- $f = Get-FullPath $r
14
- if (-not $f -or -not (Test-DirPresent $f)) { Write-Warn "scan root not found: $r"; continue }
15
- if ($f -match '^[A-Za-z]:\\?$' -or $f.Equals($U, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "refusing to scan a whole drive or your profile root: $f (name a projects folder)"; continue }
16
- $roots += $f
17
- }
18
- return $roots
19
- }
20
- $names = @('source\repos', 'source', 'repos', 'Projects', 'projects', 'code', 'Code', 'dev', 'Dev', 'Development', 'work', 'Work', 'git', 'GitHub', 'Documents\GitHub', 'Documents\Projects', 'Documents\code', 'Documents\dev', 'Documents\repos')
21
- foreach ($n in $names) { $p = Join-Path $U $n; if (Test-DirPresent $p) { $roots += (Get-FullPath $p) } }
22
- foreach ($d in [IO.DriveInfo]::GetDrives()) {
23
- if (-not $d.IsReady -or $d.DriveType -ne [IO.DriveType]::Fixed) { continue }
24
- foreach ($n in @('work', 'code', 'projects', 'repos', 'dev', 'src', 'git', 'Projects', 'Code', 'Work')) {
25
- $p = Join-Path $d.Name $n
26
- if ((Test-DirPresent $p) -and ($roots -notcontains (Get-FullPath $p))) { $roots += (Get-FullPath $p) }
27
- }
28
- }
29
- return @($roots | Select-Object -Unique)
30
- }
31
-
32
- function Get-Targets17 {
33
- $roots = @(Get-ProjectScanRoots)
34
- $t = @()
35
- foreach ($r in $roots) { $t += (New-Target 17 'Project scan root' $r -Kind cmd -Note ($Script:WS_ARTEFACT_DIRS -join ', ')) }
36
- if ($t.Count -eq 0) { $t += (New-Target 17 'Project scan root' '(none detected - pass --scan-roots "D:\work;E:\code")' -Kind cmd) }
37
- return $t
38
- }
39
-
40
- function Test-ProjectMarker {
41
- param([string] $Dir, [string] $ArtefactName)
42
- $entries = Get-ChildEntries $Dir
43
- $names = @($entries | ForEach-Object { $_.Name })
44
- if ($ArtefactName -in 'bin', 'obj') { return [bool]($names | Where-Object { $_ -like '*.csproj' -or $_ -like '*.sln' -or $_ -like '*.fsproj' }) }
45
- foreach ($m in $Script:WS_PROJECT_MARKERS) { if ($names -contains $m) { return $true } }
46
- return ($names -contains '.git')
47
- }
48
-
49
- function Get-ProjectActivityDays {
50
- <# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
51
- param([string] $ProjectDir)
52
- $newest = [datetime]::MinValue
53
- $stack = New-Object System.Collections.Generic.Stack[string]
54
- $stack.Push($ProjectDir)
55
- $seen = 0
56
- while ($stack.Count -gt 0 -and $seen -lt 200000) {
57
- $dir = $stack.Pop()
58
- foreach ($e in (Get-ChildEntries $dir)) {
59
- $seen++
60
- if (Test-ReparsePoint $e) { continue }
61
- if ($e -is [IO.DirectoryInfo]) {
62
- if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name) { continue }
63
- $stack.Push((Remove-LongPrefix $e.FullName)); continue
64
- }
65
- $t = Get-NewestTimestampUtc $e
66
- if ($t -gt $newest) { $newest = $t }
67
- }
68
- }
69
- if ($newest -eq [datetime]::MinValue) { return (Get-IdleDays $ProjectDir) }
70
- return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
71
- }
72
-
73
- function Find-StaleArtefacts {
74
- <# .SYNOPSIS Artefact folders (node_modules, dist, ...) whose project has been idle for N+ days. #>
75
- param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 6)
76
- $ws = $Script:WS
77
- $found = New-Object System.Collections.Generic.List[object]
78
- $projectAge = @{}
79
- foreach ($root in $Roots) {
80
- $stack = New-Object System.Collections.Generic.Stack[object]
81
- $stack.Push(@($root, 0))
82
- while ($stack.Count -gt 0) {
83
- $item = $stack.Pop()
84
- $dir = [string]$item[0]; $depth = [int]$item[1]
85
- foreach ($e in (Get-ChildEntries $dir)) {
86
- if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
87
- $full = Remove-LongPrefix $e.FullName
88
- $skip = $false
89
- foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
90
- if ($skip) { continue }
91
- if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj')) {
92
- if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
93
- if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
94
- $age = $projectAge[$dir]
95
- if ($age -ge $Days) { $found.Add([pscustomobject]@{ Path = $full; Project = $dir; Age = $age; Bytes = [long](Get-DirectoryBytes $full); Root = $root }) }
96
- continue
97
- }
98
- if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
99
- if ($depth -lt $MaxDepth) { $stack.Push(@($full, $depth + 1)) }
100
- }
101
- }
102
- }
103
- return @($found | Sort-Object Bytes -Descending)
104
- }
105
-
106
- function Invoke-Section17 {
107
- $ws = $Script:WS
108
- Write-SectionIntro @(
109
- 'node_modules, dist, .next, target, vendor and friends are rebuilt by the next install or build. Only projects',
110
- "whose SOURCE files nobody touched for $($ws.Days)+ days are listed, and nothing goes without your selection.",
111
- 'Toolchain folders (.gradle, .nuget, .cargo, AppData) and .git are never entered.'
112
- )
113
- $roots = @(Get-ProjectScanRoots)
114
- if ($roots.Count -eq 0) { Write-Warn 'no project roots found. Pass --scan-roots "D:\work;E:\code" (semicolon-separated) or save scanRoots in config.json.'; return }
115
- Write-Info ("scanning: " + ($roots -join ' '))
116
- $found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
117
- if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
118
- $reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
119
- Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
120
- $i = 1; $total = [long]0
121
- foreach ($f in $found) {
122
- Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
123
- $reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
124
- $total += $f.Bytes; $i++
125
- if ($i -gt 200) { Write-Warn 'list truncated to 200 entries'; break }
126
- }
127
- Write-Info ("total: " + (Format-Bytes $total))
128
- if (-not $ws.NoReport) {
129
- $out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
130
- try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
131
- }
132
- $picks = @(Read-MultiSelect -Total ([math]::Min($found.Count, 200)) -NoAutoYes)
133
- if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
134
- if (-not $ws.DryRun) {
135
- # Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
136
- if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes)) { Write-Info 'skipped'; return }
137
- }
138
- foreach ($k in $picks) {
139
- $f = $found[$k - 1]
140
- $r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
141
- if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
142
- }
143
- }
1
+ # projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
2
+
3
+ $Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
4
+ # Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
5
+ # A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
6
+ # .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
7
+ $Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
8
+ $Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
9
+ $Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
10
+
11
+ function Get-ProjectScanRoots {
12
+ $ws = $Script:WS
13
+ $U = $Script:P.U
14
+ $roots = @()
15
+ if ($ws.ScanRoots.Count -gt 0) {
16
+ foreach ($r in $ws.ScanRoots) {
17
+ $f = Get-FullPath $r
18
+ if (-not $f -or -not (Test-DirPresent $f)) { Write-Warn "scan root not found: $r"; continue }
19
+ if ($f -match '^[A-Za-z]:\\?$' -or $f.Equals($U, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "refusing to scan a whole drive or your profile root: $f (name a projects folder)"; continue }
20
+ $roots += $f
21
+ }
22
+ return $roots
23
+ }
24
+ $names = @('source\repos', 'source', 'repos', 'Projects', 'projects', 'code', 'Code', 'dev', 'Dev', 'Development', 'work', 'Work', 'git', 'GitHub', 'Documents\GitHub', 'Documents\Projects', 'Documents\code', 'Documents\dev', 'Documents\repos')
25
+ foreach ($n in $names) { $p = Join-Path $U $n; if (Test-DirPresent $p) { $roots += (Get-FullPath $p) } }
26
+ foreach ($d in [IO.DriveInfo]::GetDrives()) {
27
+ if (-not $d.IsReady -or $d.DriveType -ne [IO.DriveType]::Fixed) { continue }
28
+ foreach ($n in @('work', 'code', 'projects', 'repos', 'dev', 'src', 'git', 'Projects', 'Code', 'Work')) {
29
+ $p = Join-Path $d.Name $n
30
+ if ((Test-DirPresent $p) -and ($roots -notcontains (Get-FullPath $p))) { $roots += (Get-FullPath $p) }
31
+ }
32
+ }
33
+ return @($roots | Select-Object -Unique)
34
+ }
35
+
36
+ function Get-Targets17 {
37
+ $roots = @(Get-ProjectScanRoots)
38
+ $t = @()
39
+ foreach ($r in $roots) { $t += (New-Target 17 'Project scan root' $r -Kind cmd -Note ($Script:WS_ARTEFACT_DIRS -join ', ')) }
40
+ if ($t.Count -eq 0) { $t += (New-Target 17 'Project scan root' '(none detected - pass --scan-roots "D:\work;E:\code")' -Kind cmd) }
41
+ return $t
42
+ }
43
+
44
+ function Test-MarkedArtefactDir {
45
+ <# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
46
+ param([string] $Dir, [string] $Name)
47
+ if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
48
+ $names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
49
+ foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
50
+ return $false
51
+ }
52
+
53
+ function Test-ProjectMarker {
54
+ param([string] $Dir, [string] $ArtefactName)
55
+ $entries = Get-ChildEntries $Dir
56
+ $names = @($entries | ForEach-Object { $_.Name })
57
+ if ($ArtefactName -in 'bin', 'obj') { return [bool]($names | Where-Object { $_ -like '*.csproj' -or $_ -like '*.sln' -or $_ -like '*.fsproj' }) }
58
+ foreach ($m in $Script:WS_PROJECT_MARKERS) { if ($names -contains $m) { return $true } }
59
+ return ($names -contains '.git')
60
+ }
61
+
62
+ function Get-ProjectActivityDays {
63
+ <# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
64
+ param([string] $ProjectDir)
65
+ $newest = [datetime]::MinValue
66
+ $stack = New-Object System.Collections.Generic.Stack[string]
67
+ $stack.Push($ProjectDir)
68
+ $seen = 0
69
+ while ($stack.Count -gt 0 -and $seen -lt 200000) {
70
+ $dir = $stack.Pop()
71
+ foreach ($e in (Get-ChildEntries $dir)) {
72
+ $seen++
73
+ if (Test-ReparsePoint $e) { continue }
74
+ if ($e -is [IO.DirectoryInfo]) {
75
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
76
+ $stack.Push((Remove-LongPrefix $e.FullName)); continue
77
+ }
78
+ $t = Get-NewestTimestampUtc $e
79
+ if ($t -gt $newest) { $newest = $t }
80
+ }
81
+ }
82
+ if ($newest -eq [datetime]::MinValue) { return (Get-IdleDays $ProjectDir) }
83
+ return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
84
+ }
85
+
86
+ function Find-StaleArtefacts {
87
+ <# .SYNOPSIS Artefact folders (node_modules, dist, ...) whose project has been idle for N+ days. #>
88
+ param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 6)
89
+ $ws = $Script:WS
90
+ $found = New-Object System.Collections.Generic.List[object]
91
+ $projectAge = @{}
92
+ foreach ($root in $Roots) {
93
+ $stack = New-Object System.Collections.Generic.Stack[object]
94
+ $stack.Push(@($root, 0))
95
+ while ($stack.Count -gt 0) {
96
+ $item = $stack.Pop()
97
+ $dir = [string]$item[0]; $depth = [int]$item[1]
98
+ foreach ($e in (Get-ChildEntries $dir)) {
99
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
100
+ $full = Remove-LongPrefix $e.FullName
101
+ $skip = $false
102
+ foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
103
+ if ($skip) { continue }
104
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
105
+ if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
106
+ if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
107
+ $age = $projectAge[$dir]
108
+ if ($age -ge $Days) { $found.Add([pscustomobject]@{ Path = $full; Project = $dir; Age = $age; Bytes = [long](Get-DirectoryBytes $full); Root = $root }) }
109
+ continue
110
+ }
111
+ if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
112
+ if ($depth -lt $MaxDepth) { $stack.Push(@($full, $depth + 1)) }
113
+ }
114
+ }
115
+ }
116
+ return @($found | Sort-Object Bytes -Descending)
117
+ }
118
+
119
+ function Invoke-Section17 {
120
+ $ws = $Script:WS
121
+ Write-SectionIntro @(
122
+ 'node_modules, dist, .next, target, vendor and friends are rebuilt by the next install or build. Only projects',
123
+ "whose SOURCE files nobody touched for $($ws.Days)+ days are listed, and nothing goes without your selection.",
124
+ 'Toolchain folders (.gradle, .nuget, .cargo, AppData) and .git are never entered.'
125
+ )
126
+ $roots = @(Get-ProjectScanRoots)
127
+ if ($roots.Count -eq 0) { Write-Warn 'no project roots found. Pass --scan-roots "D:\work;E:\code" (semicolon-separated) or save scanRoots in config.json.'; return }
128
+ Write-Info ("scanning: " + ($roots -join ' '))
129
+ $found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
130
+ if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
131
+ $reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
132
+ # One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
133
+ $offered = @($found | Select-Object -First 200)
134
+ if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
135
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
136
+ $i = 1; $total = [long]0
137
+ foreach ($f in $offered) {
138
+ Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
139
+ $reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
140
+ Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
141
+ $total += $f.Bytes; $i++
142
+ }
143
+ Write-Info ("total: " + (Format-Bytes $total))
144
+ if (-not $ws.NoReport) {
145
+ $out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
146
+ try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
147
+ }
148
+ $picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
149
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
150
+ if (-not $ws.DryRun) {
151
+ # Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
152
+ # An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
153
+ if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
154
+ }
155
+ foreach ($k in $picks) {
156
+ $f = $offered[$k - 1]
157
+ $r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
158
+ if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
159
+ }
160
+ }