windowsweep 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * windowsweep - Node.js launcher for npx and global installs.
4
+ *
5
+ * npx windowsweep (zero install)
6
+ * npm i -g windowsweep (then: windowsweep)
7
+ *
8
+ * A thin shim, nothing more. It:
9
+ * 1. refuses to run anywhere but Windows;
10
+ * 2. locates windowsweep.ps1 inside the package;
11
+ * 3. starts Windows PowerShell with -ExecutionPolicy Bypass so the machine's script policy
12
+ * cannot block the run (WINDOWSWEEP_SHELL=pwsh or --pwsh selects PowerShell 7 instead);
13
+ * 4. passes the package version and launcher facts through environment variables;
14
+ * 5. forwards every argument, stdin/stdout/stderr, Ctrl-C and the exit code unchanged.
15
+ *
16
+ * Author: Ahsan Mahmood <aoneahsan@gmail.com> https://aoneahsan.com
17
+ * License: MIT (see LICENSE)
18
+ */
19
+ 'use strict';
20
+
21
+ const path = require('path');
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const { spawn } = require('child_process');
25
+
26
+ if (process.platform !== 'win32') {
27
+ process.stderr.write(
28
+ '\nwindowsweep runs on Windows only. Detected platform: ' + process.platform + '\n' +
29
+ 'Linux: npx linux-cleanup macOS: npx macleanup\n\n'
30
+ );
31
+ process.exit(2);
32
+ }
33
+
34
+ const PKG_DIR = path.resolve(__dirname, '..');
35
+ const SCRIPT = path.join(PKG_DIR, 'windowsweep.ps1');
36
+ if (!fs.existsSync(SCRIPT)) {
37
+ process.stderr.write('windowsweep: cannot find ' + SCRIPT + ' - the package may be corrupted. Try: npx --yes windowsweep@latest\n');
38
+ process.exit(2);
39
+ }
40
+
41
+ let pkgVersion = '';
42
+ try { pkgVersion = require(path.join(PKG_DIR, 'package.json')).version || ''; } catch (_) { /* fall back to the literal in lib/constants.ps1 */ }
43
+
44
+ const rawArgs = process.argv.slice(2);
45
+ const wantPwsh = rawArgs.includes('--pwsh') || (process.env.WINDOWSWEEP_SHELL || '').toLowerCase() === 'pwsh';
46
+ const args = rawArgs.filter((a) => a !== '--pwsh');
47
+
48
+ const systemRoot = process.env.SystemRoot || 'C:\\Windows';
49
+ const windowsPowerShell = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
50
+ const shell = wantPwsh ? 'pwsh' : (fs.existsSync(windowsPowerShell) ? windowsPowerShell : 'powershell.exe');
51
+
52
+ const isNpx = /[\\/]_npx[\\/]/.test(PKG_DIR) || /npx/i.test(process.env.npm_config_user_agent || '');
53
+
54
+ const env = Object.assign({}, process.env, {
55
+ WINDOWSWEEP_VERSION: pkgVersion,
56
+ WINDOWSWEEP_LAUNCHER: 'node',
57
+ WINDOWSWEEP_NPX: isNpx ? '1' : '0',
58
+ });
59
+
60
+ const child = spawn(shell, ['-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT].concat(args), {
61
+ stdio: 'inherit',
62
+ env: env,
63
+ windowsHide: false,
64
+ });
65
+
66
+ child.on('error', (err) => {
67
+ if (err.code === 'ENOENT') {
68
+ process.stderr.write('\nwindowsweep: ' + shell + ' was not found. Windows PowerShell 5.1 ships with Windows 10/11;' +
69
+ (wantPwsh ? ' install PowerShell 7 or drop --pwsh.\n\n' : ' check that %SystemRoot%\\System32\\WindowsPowerShell exists.\n\n'));
70
+ } else {
71
+ process.stderr.write('windowsweep: failed to start PowerShell: ' + err.message + '\n');
72
+ }
73
+ process.exit(2);
74
+ });
75
+
76
+ child.on('exit', (code, signal) => {
77
+ if (signal) { process.exit(128 + (os.constants.signals[signal] || 0)); }
78
+ process.exit(code === null ? 0 : code);
79
+ });
80
+
81
+ ['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((sig) => {
82
+ process.on(sig, () => { if (!child.killed) { child.kill(sig); } });
83
+ });
@@ -0,0 +1,209 @@
1
+ # actions.ps1 - the target-list engine every section drives, plus shared cache-layout helpers.
2
+ # A section is mostly a declaration: New-Target rows (path + mode + guards) run by Invoke-TargetList.
3
+
4
+ $Script:WS_CHROMIUM_PROFILE_CACHES = @('Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'GrShaderCache', 'ShaderCache', 'Service Worker\ScriptCache')
5
+ $Script:WS_CHROMIUM_ROOT_CACHES = @('ShaderCache', 'GrShaderCache', 'GraphiteDawnCache', 'Crashpad\completed', 'Crashpad\reports', 'SwReporter')
6
+ $Script:WS_ELECTRON_CACHES = @('Cache', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'GrShaderCache', 'ShaderCache', 'Service Worker\CacheStorage', 'Service Worker\ScriptCache', 'Crashpad\completed', 'Crashpad\reports')
7
+ $Script:WS_EDITOR_CACHES = @('Cache', 'CachedData', 'CachedProfilesData', 'CachedExtensionVSIXs', 'Code Cache', 'GPUCache', 'DawnCache', 'DawnGraphiteCache', 'DawnWebGPUCache', 'Service Worker\CacheStorage', 'Service Worker\ScriptCache', 'Crashpad\completed', 'Crashpad\reports')
8
+ $Script:WS_FIREFOX_CACHES = @('cache2', 'startupCache', 'thumbnails', 'jumpListCache', 'OfflineCache', 'shader-cache')
9
+
10
+ function Get-ChromiumProfileDirs {
11
+ <# .SYNOPSIS Profile folders of a Chromium "User Data" root. A root that itself holds Cache is its own profile (Opera, Electron). #>
12
+ param([string] $Root)
13
+ $out = @()
14
+ if (-not (Test-DirPresent $Root)) { return $out }
15
+ foreach ($e in (Get-ChildEntries $Root)) {
16
+ if (-not ($e -is [IO.DirectoryInfo])) { continue }
17
+ if ($e.Name -match '^(Default|Profile \d+|Guest Profile|System Profile)$') { $out += (Remove-LongPrefix $e.FullName) }
18
+ }
19
+ if ($out.Count -eq 0 -and (Test-DirPresent (Join-Path $Root 'Cache'))) { $out += (Get-FullPath $Root) }
20
+ return $out
21
+ }
22
+
23
+ function Get-ChromiumCacheDirs {
24
+ <# .SYNOPSIS Existing regenerable cache folders under a Chromium root (per profile + root-level). Never profile data. #>
25
+ param([string] $Root)
26
+ $out = @()
27
+ foreach ($prof in (Get-ChromiumProfileDirs $Root)) {
28
+ foreach ($sub in $Script:WS_CHROMIUM_PROFILE_CACHES) { $p = Join-Path $prof $sub; if (Test-DirPresent $p) { $out += $p } }
29
+ }
30
+ foreach ($sub in $Script:WS_CHROMIUM_ROOT_CACHES) { $p = Join-Path $Root $sub; if (Test-DirPresent $p) { $out += $p } }
31
+ return @($out | Select-Object -Unique)
32
+ }
33
+
34
+ function Get-FirefoxCacheDirs {
35
+ param([string] $ProfilesRoot)
36
+ $out = @()
37
+ if (-not (Test-DirPresent $ProfilesRoot)) { return $out }
38
+ foreach ($prof in (Get-ChildEntries $ProfilesRoot)) {
39
+ if (-not ($prof -is [IO.DirectoryInfo])) { continue }
40
+ foreach ($sub in $Script:WS_FIREFOX_CACHES) { $p = Join-Path (Remove-LongPrefix $prof.FullName) $sub; if (Test-DirPresent $p) { $out += $p } }
41
+ }
42
+ return $out
43
+ }
44
+
45
+ function Get-SubdirCacheDirs {
46
+ param([string] $Root, [string[]] $Subdirs)
47
+ $out = @()
48
+ if (-not (Test-DirPresent $Root)) { return $out }
49
+ foreach ($sub in $Subdirs) { $p = Join-Path $Root $sub; if (Test-DirPresent $p) { $out += $p } }
50
+ return $out
51
+ }
52
+
53
+ function Get-TargetCachePaths {
54
+ <# .SYNOPSIS Concrete cache folders for the layout kinds (chromium | firefox | electron | editor). #>
55
+ param([pscustomobject] $Target)
56
+ $roots = @()
57
+ if ($Target.Path -match '[\*\?]') {
58
+ try { $roots = @(Get-Item -Path $Target.Path -Force -ErrorAction SilentlyContinue | Where-Object { $_ -is [IO.DirectoryInfo] } | ForEach-Object { $_.FullName }) } catch { $roots = @() }
59
+ } elseif (Test-DirPresent $Target.Path) { $roots = @((Get-FullPath $Target.Path)) }
60
+ $out = @()
61
+ foreach ($r in $roots) {
62
+ switch ($Target.Kind) {
63
+ 'chromium' { $out += Get-ChromiumCacheDirs $r }
64
+ 'firefox' { $out += Get-FirefoxCacheDirs $r }
65
+ 'electron' { $out += Get-SubdirCacheDirs -Root $r -Subdirs $Script:WS_ELECTRON_CACHES }
66
+ 'editor' { $out += Get-SubdirCacheDirs -Root $r -Subdirs $Script:WS_EDITOR_CACHES }
67
+ }
68
+ }
69
+ return @($out)
70
+ }
71
+
72
+ function Show-TargetSizes {
73
+ <# .SYNOPSIS Table of the given targets with their current size on disk. Returns total bytes. Skipped when --quiet. #>
74
+ param([pscustomobject[]] $Targets)
75
+ $total = [long]0
76
+ if ($Script:WS.Quiet) { return $total }
77
+ Write-UiLine (" {0,-44} {1,10} {2}" -f 'TARGET', 'ON DISK', 'PATH') 'White'
78
+ foreach ($t in $Targets) {
79
+ if ($t.Kind -eq 'cmd') { Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, '(cmd)', $t.Path) 'DarkGray'; continue }
80
+ $paths = @(Resolve-TargetPaths $t)
81
+ if ($paths.Count -eq 0) { Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, 'absent', $t.Path) 'DarkGray'; continue }
82
+ $bytes = [long]0
83
+ foreach ($p in $paths) { $bytes += Get-DirectoryBytes $p }
84
+ $total += $bytes
85
+ $shown = $t.Path
86
+ if ($paths.Count -gt 1) { $shown = "$($t.Path) ($($paths.Count) folders)" }
87
+ Write-UiLine (" {0,-44} {1,10} {2}" -f $t.Label, (Format-Bytes $bytes), $shown) 'Gray'
88
+ }
89
+ Write-UiLine (" {0,-44} {1,10}" -f 'on disk now', (Format-Bytes $total)) 'Cyan'
90
+ return $total
91
+ }
92
+
93
+ function Confirm-Section {
94
+ <# .SYNOPSIS One confirmation per section. Dry-run needs none; the walkthrough's step prompt counts as one. #>
95
+ param([string] $Prompt = 'Proceed with this section?', [string] $Default = 'y')
96
+ $ws = $Script:WS
97
+ if ($ws.DryRun) { Write-Note '[dry-run] estimating only - nothing is deleted'; return $true }
98
+ if ($ws.SectionPreConfirmed) { return $true }
99
+ return (Confirm-Ui -Prompt $Prompt -Default $Default)
100
+ }
101
+
102
+ function Invoke-TargetList {
103
+ <# .SYNOPSIS Run every target with its mode under the dry-run, developer, purge and running-app policies. Returns bytes freed/estimated. #>
104
+ param([pscustomobject[]] $Targets)
105
+ $ws = $Script:WS
106
+ $total = [long]0
107
+ foreach ($t in $Targets) {
108
+ if ($t.Kind -eq 'cmd') { continue }
109
+ $paths = @(Resolve-TargetPaths $t)
110
+ if ($paths.Count -eq 0) { Write-Info "$($t.Label) - absent"; continue }
111
+ if (@($t.Guard).Count -gt 0 -and (Test-ProcessRunning $t.Guard)) {
112
+ Write-Warn "$($t.Label) - skipped: $($t.Guard[0]) is running (close it and run this section again)"
113
+ $ws.Hints += "Close $($t.Guard[0]) and re-run: windowsweep --only $($t.Section) --yes (to clear $($t.Label))"
114
+ continue
115
+ }
116
+ $mode = $t.Mode
117
+ if ($t.Kind -in 'chromium', 'firefox', 'electron', 'editor') {
118
+ $mode = 'clear'
119
+ # Second guard: a layout target may only ever clear a known cache folder, never a profile or app root.
120
+ $known = @($Script:WS_CHROMIUM_PROFILE_CACHES + $Script:WS_CHROMIUM_ROOT_CACHES + $Script:WS_ELECTRON_CACHES + $Script:WS_EDITOR_CACHES + $Script:WS_FIREFOX_CACHES | ForEach-Object { [IO.Path]::GetFileName($_) })
121
+ $paths = @($paths | Where-Object {
122
+ $leaf = [IO.Path]::GetFileName($_)
123
+ if ($known -contains $leaf) { $true } else { Write-Err "REFUSE (not a known cache folder for a $($t.Kind) layout): $_"; $false }
124
+ })
125
+ }
126
+ if ($ws.PurgeAll -and $mode -in 'prune', 'units') { $mode = 'clear' }
127
+ if ($t.Dev -and $ws.Developer -eq $false -and $mode -in 'prune', 'units') { $mode = 'clear' }
128
+ $days = $t.Days
129
+ if ($days -le 0) { $days = $ws.Days }
130
+ # A target that resolves to several folders (every browser profile, every cache name) labels each result
131
+ # line with "parent\leaf", so ten "VS Code caches - cleared ..." lines become ten distinguishable ones.
132
+ $multi = ($paths.Count -gt 1)
133
+ foreach ($p in $paths) {
134
+ $label = "$($t.Label)"
135
+ if ($multi) { $label = "$($t.Label) [$(Split-Path -Leaf (Split-Path -Parent $p))\$([IO.Path]::GetFileName($p))]" }
136
+ switch ($mode) {
137
+ 'clear' { $r = Clear-DirectoryContents -Path $p -Within $p -Label $label; $total += $r.Freed }
138
+ 'clear-old' { $r = Clear-DirectoryContents -Path $p -Within $p -Label $label -OlderThanDays $days; $total += $r.Freed }
139
+ 'prune' { $r = Remove-StaleFiles -Root $p -Within $p -Days $days -Label $label; $total += $r.Freed }
140
+ 'units' { $r = Remove-StaleUnits -Root $p -Within $p -Days $days -KeepNewest:$t.KeepNewest -DirectoriesOnly -GroupBy $t.GroupBy -ExcludeNames $t.ExcludeNames -Label $label; $total += $r.Freed }
141
+ 'file' {
142
+ $r = Remove-PathSafe -Path $p -Within (Split-Path -Parent $p) -Label "$($t.Label)"
143
+ if ($r.Removed -and -not $ws.DryRun -and $r.Bytes -gt 0) { Write-Ok ("$($t.Label) - removed " + (Format-Bytes $r.Bytes)) }
144
+ $total += $r.Bytes
145
+ }
146
+ default { Write-Warn "$($t.Label) - unknown mode '$mode'" }
147
+ }
148
+ }
149
+ }
150
+ return $total
151
+ }
152
+
153
+ function Remove-SupersededVersions {
154
+ <# .SYNOPSIS Squirrel-style installs keep old app-x.y.z folders beside the current one; remove all but the highest version. #>
155
+ param([string] $Root, [string] $Prefix = 'app-', [string[]] $Guard = @(), [string] $Label = '')
156
+ if (-not (Test-DirPresent $Root)) { return [long]0 }
157
+ if ($Guard.Count -gt 0 -and (Test-ProcessRunning $Guard)) { Write-Warn "$Label - superseded versions skipped: $($Guard[0]) is running"; return [long]0 }
158
+ $versions = @()
159
+ foreach ($e in (Get-ChildEntries $Root)) {
160
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
161
+ if ($e.Name -like "$Prefix*") {
162
+ $v = $null
163
+ try { $v = [version]($e.Name.Substring($Prefix.Length) -replace '[^0-9.].*$', '') } catch { $v = $null }
164
+ if ($v) { $versions += [pscustomobject]@{ Name = $e.Name; Path = (Remove-LongPrefix $e.FullName); Version = $v } }
165
+ }
166
+ }
167
+ if ($versions.Count -lt 2) { return [long]0 }
168
+ $newest = $versions | Sort-Object Version -Descending | Select-Object -First 1
169
+ $freed = [long]0
170
+ foreach ($v in $versions) {
171
+ if ($v.Path -eq $newest.Path) { Write-Note "kept $($v.Name) (current version)"; continue }
172
+ $r = Remove-PathSafe -Path $v.Path -Within $Root -Label $Label
173
+ if ($r.Removed) { $freed += $r.Bytes; if (-not $Script:WS.DryRun) { Write-Ok ("$Label - removed superseded $($v.Name) (" + (Format-Bytes $r.Bytes) + ')') } }
174
+ }
175
+ return $freed
176
+ }
177
+
178
+ function ConvertFrom-SizeText {
179
+ <# .SYNOPSIS "2.891GB" / "12.5 MB" / "20.48kB" -> bytes (docker prints decimal units). #>
180
+ param([string] $Text)
181
+ if ($Text -match '([\d.]+)\s*(B|kB|KB|MB|GB|TB)') {
182
+ $n = [double]$Matches[1]
183
+ switch ($Matches[2]) { 'kB' { return [long]($n * 1000) } 'KB' { return [long]($n * 1000) } 'MB' { return [long]($n * 1e6) } 'GB' { return [long]($n * 1e9) } 'TB' { return [long]($n * 1e12) } default { return [long]$n } }
184
+ }
185
+ return [long]0
186
+ }
187
+
188
+ function Get-NpmCacheDir {
189
+ $fallback = Join-Path $Script:P.L 'npm-cache'
190
+ if (-not (Test-CommandPresent 'npm')) { return $fallback }
191
+ try {
192
+ $out = & npm config get cache 2>$null
193
+ $line = ($out | Select-Object -Last 1)
194
+ if ($line -and (Test-DirPresent $line.Trim())) { return (Get-FullPath $line.Trim()) }
195
+ } catch { $null = $_ }
196
+ return $fallback
197
+ }
198
+
199
+ function Write-SectionIntro {
200
+ <# .SYNOPSIS Print what a section does and the current strategy line. #>
201
+ param([string[]] $Lines, [bool] $Dev = $false)
202
+ $ws = $Script:WS
203
+ foreach ($l in $Lines) { Write-Plain " $l" }
204
+ if ($Dev) {
205
+ if ($ws.PurgeAll) { Write-Warn 'strategy: FULL PURGE (--purge-all) - every cache target is emptied, including recently used files' }
206
+ elseif ($ws.Developer -eq $false) { Write-Info 'strategy: developer mode OFF - these caches are cleared completely' }
207
+ else { Write-Info "strategy: developer mode ON - only files idle $($ws.Days)+ days go; the newest version of each tool cache is kept" }
208
+ }
209
+ }
package/lib/config.ps1 ADDED
@@ -0,0 +1,90 @@
1
+ # config.ps1 - persistent settings (~\.windowsweep\config.json) and the developer question.
2
+
3
+ function Get-DefaultConfig {
4
+ return [ordered]@{
5
+ schema = 1
6
+ developer = $null
7
+ developerAskedAt = $null
8
+ days = 100
9
+ tempDays = 3
10
+ largeFileMb = 100
11
+ scanRoots = @()
12
+ excludePaths = @()
13
+ welcomed = $false
14
+ }
15
+ }
16
+
17
+ function Import-Config {
18
+ <# .SYNOPSIS Load config.json over the defaults; a broken file is reported and ignored. #>
19
+ $ws = $Script:WS
20
+ $cfg = Get-DefaultConfig
21
+ $path = $ws.ConfigPath
22
+ if (Test-Path -LiteralPath $path) {
23
+ try {
24
+ $raw = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
25
+ foreach ($k in @($cfg.Keys)) {
26
+ $prop = $raw.PSObject.Properties[$k]
27
+ if ($null -ne $prop) { $cfg[$k] = $prop.Value }
28
+ }
29
+ if ($null -eq $cfg.scanRoots) { $cfg.scanRoots = @() }
30
+ if ($null -eq $cfg.excludePaths) { $cfg.excludePaths = @() }
31
+ } catch {
32
+ Write-Warn "config file unreadable, using defaults: $path ($($_.Exception.Message))"
33
+ }
34
+ }
35
+ $ws.Config = $cfg
36
+ return $cfg
37
+ }
38
+
39
+ function Save-Config {
40
+ $ws = $Script:WS
41
+ try {
42
+ $dir = Split-Path -Parent $ws.ConfigPath
43
+ if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
44
+ [IO.File]::WriteAllText($ws.ConfigPath, ($ws.Config | ConvertTo-Json -Depth 4), (New-Object System.Text.UTF8Encoding($false)))
45
+ } catch {
46
+ Write-Warn "could not save config: $($_.Exception.Message)"
47
+ }
48
+ }
49
+
50
+ function Get-ToolchainHints {
51
+ <# .SYNOPSIS Names of developer tools found on this machine (used to suggest the developer answer). #>
52
+ $found = @()
53
+ foreach ($c in @('node', 'npm', 'yarn', 'pnpm', 'bun', 'deno', 'git', 'docker', 'python', 'pip', 'uv', 'java', 'gradle', 'dotnet', 'cargo', 'go', 'flutter', 'composer', 'php', 'code', 'cursor')) {
54
+ if (Get-Command $c -ErrorAction SilentlyContinue) { $found += $c }
55
+ }
56
+ $dirs = @{
57
+ 'Gradle caches' = "$env:USERPROFILE\.gradle"; 'Android SDK' = "$env:LOCALAPPDATA\Android\Sdk"; 'VS Code' = "$env:APPDATA\Code"
58
+ 'Docker Desktop' = "$env:LOCALAPPDATA\Docker"; 'JetBrains IDEs' = "$env:LOCALAPPDATA\JetBrains"; 'Visual Studio' = "$env:LOCALAPPDATA\Microsoft\VisualStudio"
59
+ }
60
+ foreach ($k in $dirs.Keys) { if (Test-Path -LiteralPath $dirs[$k]) { $found += $k } }
61
+ return $found
62
+ }
63
+
64
+ function Resolve-DeveloperMode {
65
+ <# .SYNOPSIS Decide developer mode: flag > saved answer > interactive question > conservative default (yes). #>
66
+ $ws = $Script:WS
67
+ if ($null -ne $ws.DeveloperFlag) { $ws.Developer = [bool]$ws.DeveloperFlag; $ws.DeveloperSource = 'flag'; return }
68
+ if ($ws.ForgetDeveloper) { $ws.Config.developer = $null; $ws.Config.developerAskedAt = $null }
69
+ if ($null -ne $ws.Config.developer) { $ws.Developer = [bool]$ws.Config.developer; $ws.DeveloperSource = 'config'; return }
70
+ if ($ws.Interactive -and -not $ws.Yes) {
71
+ $hints = @(Get-ToolchainHints)
72
+ Write-Box 'One question before anything else' 'It decides how package and build caches are treated'
73
+ Write-Plain ' Are you a developer on this machine?'
74
+ Write-Note 'yes = package/build/test-runner caches are only pruned when idle 100+ days (recent work stays fast);'
75
+ Write-Note ' the newest version of every versioned tool cache is always kept.'
76
+ Write-Note 'no = those caches are cleared completely; project scans are skipped.'
77
+ if ($hints.Count -gt 0) { Write-Note ("detected developer tooling: " + ($hints -join ', ')) }
78
+ $ans = Confirm-Ui -Prompt 'Are you a developer on this machine?' -Default 'y' -NoAutoYes
79
+ $ws.Developer = $ans
80
+ $ws.DeveloperSource = 'answer'
81
+ $ws.Config.developer = $ans
82
+ $ws.Config.developerAskedAt = (Get-Date).ToString('yyyy-MM-dd')
83
+ Save-Config
84
+ if ($ans) { Write-Ok 'developer mode ON - saved to config.json (change with --forget-developer)' } else { Write-Ok 'developer mode OFF - saved to config.json (change with --forget-developer)' }
85
+ return
86
+ }
87
+ $ws.Developer = $true
88
+ $ws.DeveloperSource = 'default'
89
+ Write-Note 'developer question not asked (non-interactive run) - defaulting to developer mode ON, the conservative choice; pass --not-developer to override'
90
+ }
@@ -0,0 +1,89 @@
1
+ # constants.ps1 - project metadata, section catalogue, profiles and gates.
2
+ # Dot-sourced by windowsweep.ps1. ASCII only (PowerShell 5.1 reads BOM-less files as ANSI).
3
+
4
+ # Single source of truth for the version is package.json: the Node launcher exports it as
5
+ # WINDOWSWEEP_VERSION. A direct checkout falls back to this literal; the version:check script
6
+ # asserts the two never drift.
7
+ $Script:WS_VERSION_FALLBACK = '1.0.0'
8
+
9
+ $Script:WS_NAME = 'windowsweep'
10
+ $Script:WS_TAGLINE = 'Safe-by-default Windows cleanup CLI - developer-aware, dry-run first, zero install via npx.'
11
+ $Script:WS_AUTHOR = 'Ahsan Mahmood'
12
+ $Script:WS_EMAIL = 'aoneahsan@gmail.com'
13
+ $Script:WS_WEB = 'https://aoneahsan.com'
14
+ $Script:WS_LINKEDIN = 'https://linkedin.com/in/aoneahsan'
15
+ $Script:WS_GITHUB = 'https://github.com/aoneahsan'
16
+ $Script:WS_REPO = 'https://github.com/aoneahsan/windowsweep'
17
+ $Script:WS_ISSUES = 'https://github.com/aoneahsan/windowsweep/issues'
18
+ $Script:WS_NPM = 'https://www.npmjs.com/package/windowsweep'
19
+ $Script:WS_DOCS = 'https://github.com/aoneahsan/windowsweep/blob/main/docs/README.md'
20
+ $Script:WS_SUPPORT = 'https://aoneahsan.com/payment?project-id=windowsweep&project-identifier=windowsweep'
21
+ $Script:WS_LICENSE = 'MIT License'
22
+
23
+ # Exit codes (documented in docs/cli-reference.md).
24
+ $Script:WS_EXIT_OK = 0
25
+ $Script:WS_EXIT_FAIL = 1
26
+ $Script:WS_EXIT_USAGE = 2
27
+ $Script:WS_EXIT_REFUSED = 3
28
+ $Script:WS_EXIT_INTERRUPT = 130
29
+
30
+ # Section catalogue. Numbering is a public contract frozen at 1.0.0: a section may be retired
31
+ # (kept as a no-op that says so) but a number is never reused for something else.
32
+ # Tier: report | rebuilds | slow | permanent | config
33
+ # Batch: safe (in --all) | optin (--only/--profile with --yes) | deep (needs --i-understand-deep)
34
+ # | interactive (never batch; dry-run/report allowed)
35
+ $Script:WS_SECTIONS = @(
36
+ @{ Id = 0; Key = 'health'; Title = 'System health report'; Tier = 'report'; Admin = $false; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section00' }
37
+ @{ Id = 1; Key = 'pkg'; Title = 'Package-manager caches (npm, yarn, pnpm, bun, deno, pip, uv, Composer, NuGet, Cargo, Go, pub)'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $true; Fn = 'Invoke-Section01' }
38
+ @{ Id = 2; Key = 'build'; Title = 'Build-tool caches (Gradle, Maven, Android, Unity, JetBrains)'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $true; Fn = 'Invoke-Section02' }
39
+ @{ Id = 3; Key = 'runners'; Title = 'Test-runner browsers (Cypress, Playwright, Puppeteer) - keep newest'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $true; Fn = 'Invoke-Section03' }
40
+ @{ Id = 4; Key = 'avd'; Title = 'Android emulators (AVDs) idle N+ days'; Tier = 'slow'; Admin = $false; Batch = 'optin'; Dev = $true; Fn = 'Invoke-Section04' }
41
+ @{ Id = 5; Key = 'docker'; Title = 'Docker: dangling images, build cache, unused images older than N days'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $true; Fn = 'Invoke-Section05' }
42
+ @{ Id = 6; Key = 'editors'; Title = 'Editor caches (VS Code, Cursor, Windsurf, Visual Studio) + superseded extensions'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section06' }
43
+ @{ Id = 7; Key = 'browsers'; Title = 'Browser caches (Chrome, Edge, Brave, Vivaldi, Opera, Chromium, Firefox)'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section07' }
44
+ @{ Id = 8; Key = 'apps'; Title = 'Desktop app caches (Discord, Slack, Teams, Zoom, Spotify, Postman, Figma, ...)'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section08' }
45
+ @{ Id = 9; Key = 'wincaches'; Title = 'Windows user caches (INetCache, WER, crash dumps, shader caches, UWP temp)'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section09' }
46
+ @{ Id = 10; Key = 'temp'; Title = 'User temp files older than N days'; Tier = 'rebuilds'; Admin = $false; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section10' }
47
+ @{ Id = 11; Key = 'recycle'; Title = 'Empty the Recycle Bin - PERMANENT'; Tier = 'permanent'; Admin = $false; Batch = 'deep'; Dev = $false; Fn = 'Invoke-Section11' }
48
+ @{ Id = 12; Key = 'wu'; Title = 'Windows Update + system temp (SoftwareDistribution, Delivery Optimization, Windows\Temp, CBS logs)'; Tier = 'rebuilds'; Admin = $true; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section12' }
49
+ @{ Id = 13; Key = 'cleanmgr'; Title = 'Windows Disk Cleanup engine (cleanmgr, curated handlers)'; Tier = 'rebuilds'; Admin = $true; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section13' }
50
+ @{ Id = 14; Key = 'dism'; Title = 'Component store cleanup (DISM StartComponentCleanup) - slow'; Tier = 'rebuilds'; Admin = $true; Batch = 'optin'; Dev = $false; Fn = 'Invoke-Section14' }
51
+ @{ Id = 15; Key = 'hiberfil'; Title = 'Hibernation file (off / reduced)'; Tier = 'config'; Admin = $true; Batch = 'deep'; Dev = $false; Fn = 'Invoke-Section15' }
52
+ @{ Id = 16; Key = 'eventlogs'; Title = 'Clear Windows Event Logs - PERMANENT'; Tier = 'permanent'; Admin = $true; Batch = 'deep'; Dev = $false; Fn = 'Invoke-Section16' }
53
+ @{ Id = 17; Key = 'projects'; Title = 'Stale project build artefacts (node_modules, dist, .next, target, ...)'; Tier = 'rebuilds'; Admin = $false; Batch = 'interactive'; Dev = $true; Fn = 'Invoke-Section17' }
54
+ @{ Id = 18; Key = 'partials'; Title = 'Partial / orphan downloads -> Recycle Bin'; Tier = 'permanent'; Admin = $false; Batch = 'interactive'; Dev = $false; Fn = 'Invoke-Section18' }
55
+ @{ Id = 19; Key = 'large'; Title = 'Large stale personal files (Downloads, Desktop) -> Recycle Bin'; Tier = 'permanent'; Admin = $false; Batch = 'interactive'; Dev = $false; Fn = 'Invoke-Section19' }
56
+ @{ Id = 20; Key = 'vhdx'; Title = 'Docker Desktop / WSL disk image compaction (stops Docker + WSL)'; Tier = 'config'; Admin = $true; Batch = 'deep'; Dev = $true; Fn = 'Invoke-Section20' }
57
+ @{ Id = 21; Key = 'diskusage'; Title = 'Disk usage report (largest entries, drives, disk images)'; Tier = 'report'; Admin = $false; Batch = 'safe'; Dev = $false; Fn = 'Invoke-Section21' }
58
+ )
59
+
60
+ # The safe batch run by --all. Admin sections 12 and 13 join it only when already elevated.
61
+ $Script:WS_SAFE_BATCH = @(0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 21)
62
+ $Script:WS_SAFE_BATCH_ADMIN = @(12, 13)
63
+
64
+ $Script:WS_PROFILES = @{
65
+ 'dev' = @(1, 2, 3, 4, 5, 6, 17)
66
+ 'minimal' = @(7, 8, 9, 10)
67
+ 'cache-only' = @(1, 2, 3, 6, 7, 8, 9)
68
+ 'system' = @(12, 13, 14)
69
+ 'deep' = @(0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 14, 21)
70
+ 'audit' = @(0, 21)
71
+ }
72
+
73
+ # Walkthrough order (interactive default mode). Admin sections are inserted when elevated.
74
+ $Script:WS_WALKTHROUGH = @(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 17, 18, 19)
75
+ $Script:WS_WALKTHROUGH_ADMIN = @(12, 13, 14)
76
+
77
+ function Get-Section {
78
+ <# .SYNOPSIS Returns the catalogue entry for a section id, or $null. #>
79
+ param([int] $Id)
80
+ foreach ($s in $Script:WS_SECTIONS) { if ($s.Id -eq $Id) { return $s } }
81
+ return $null
82
+ }
83
+
84
+ function Get-ToolVersion {
85
+ <# .SYNOPSIS The running version: launcher-provided env wins over the checkout fallback. #>
86
+ $v = $env:WINDOWSWEEP_VERSION
87
+ if ([string]::IsNullOrWhiteSpace($v)) { return $Script:WS_VERSION_FALLBACK }
88
+ return $v.Trim()
89
+ }
package/lib/fs.ps1 ADDED
@@ -0,0 +1,180 @@
1
+ # fs.ps1 - long-path-safe filesystem helpers. The walker never descends into reparse points
2
+ # (junctions / symlinks), which .NET's own recursive enumeration does.
3
+
4
+ function Get-LongPath {
5
+ <# .SYNOPSIS Prefix an absolute path with \\?\ so .NET accepts paths longer than 260 chars. #>
6
+ param([string] $Path)
7
+ if ($Path.StartsWith('\\?\')) { return $Path }
8
+ if ($Path.StartsWith('\\')) { return ('\\?\UNC\' + $Path.Substring(2)) }
9
+ return ('\\?\' + $Path)
10
+ }
11
+
12
+ function Get-FullPath {
13
+ <# .SYNOPSIS Expand env vars, resolve . and .., strip the long prefix and trailing separators. $null if unresolvable. #>
14
+ param([string] $Path)
15
+ if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
16
+ $p = [Environment]::ExpandEnvironmentVariables($Path.Trim())
17
+ if ($p.StartsWith('\\?\UNC\')) { $p = '\\' + $p.Substring(8) } elseif ($p.StartsWith('\\?\')) { $p = $p.Substring(4) }
18
+ try { $p = [IO.Path]::GetFullPath($p) } catch { return $null }
19
+ if ($p.Length -gt 3) { $p = $p.TrimEnd('\') }
20
+ return $p
21
+ }
22
+
23
+ function Get-ItemInfo {
24
+ <# .SYNOPSIS FileInfo or DirectoryInfo for a path (long-path aware), or $null when absent. #>
25
+ param([string] $Path)
26
+ $lp = Get-LongPath $Path
27
+ try {
28
+ if ([IO.Directory]::Exists($lp)) { return (New-Object IO.DirectoryInfo($lp)) }
29
+ if ([IO.File]::Exists($lp)) { return (New-Object IO.FileInfo($lp)) }
30
+ } catch { $null = $_ }
31
+ return $null
32
+ }
33
+
34
+ function Test-PathPresent {
35
+ param([string] $Path)
36
+ $lp = Get-LongPath $Path
37
+ try { return ([IO.Directory]::Exists($lp) -or [IO.File]::Exists($lp)) } catch { return $false }
38
+ }
39
+
40
+ function Test-DirPresent {
41
+ param([string] $Path)
42
+ try { return [IO.Directory]::Exists((Get-LongPath $Path)) } catch { return $false }
43
+ }
44
+
45
+ function Test-ReparsePoint {
46
+ <# .SYNOPSIS True when the FileSystemInfo is a junction or symbolic link. #>
47
+ param([System.IO.FileSystemInfo] $Info)
48
+ if ($null -eq $Info) { return $false }
49
+ return (($Info.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
50
+ }
51
+
52
+ function Get-ChildEntries {
53
+ <# .SYNOPSIS Non-recursive children of a directory; empty on access errors. #>
54
+ param([string] $Path)
55
+ $out = @()
56
+ try {
57
+ $di = New-Object IO.DirectoryInfo((Get-LongPath $Path))
58
+ foreach ($e in $di.EnumerateFileSystemInfos()) { $out += $e }
59
+ } catch { $null = $_ }
60
+ return $out
61
+ }
62
+
63
+ function Get-NewestTimestampUtc {
64
+ <# .SYNOPSIS The most recent of write / access / creation time - the safe "last touched" estimate when access tracking is off. #>
65
+ param([System.IO.FileSystemInfo] $Info)
66
+ $t = $Info.LastWriteTimeUtc
67
+ try { if ($Info.LastAccessTimeUtc -gt $t) { $t = $Info.LastAccessTimeUtc } } catch { $null = $_ }
68
+ try { if ($Info.CreationTimeUtc -gt $t) { $t = $Info.CreationTimeUtc } } catch { $null = $_ }
69
+ return $t
70
+ }
71
+
72
+ function Get-DirectoryStats {
73
+ <# .SYNOPSIS Walk a tree without following reparse points. Returns Bytes, Files, Dirs, Links, Newest (UTC), Errors. #>
74
+ param([string] $Path)
75
+ $bytes = [long]0; $files = 0; $dirs = 0; $links = 0; $errors = 0
76
+ $newest = [datetime]::MinValue
77
+ $root = Get-ItemInfo $Path
78
+ if ($null -eq $root) { return [pscustomobject]@{ Bytes = 0; Files = 0; Dirs = 0; Links = 0; Newest = $newest; Errors = 0; Exists = $false } }
79
+ if ($root -is [IO.FileInfo]) {
80
+ return [pscustomobject]@{ Bytes = [long]$root.Length; Files = 1; Dirs = 0; Links = 0; Newest = (Get-NewestTimestampUtc $root); Errors = 0; Exists = $true }
81
+ }
82
+ if (Test-ReparsePoint $root) {
83
+ return [pscustomobject]@{ Bytes = 0; Files = 0; Dirs = 0; Links = 1; Newest = (Get-NewestTimestampUtc $root); Errors = 0; Exists = $true }
84
+ }
85
+ $stack = New-Object System.Collections.Generic.Stack[System.IO.DirectoryInfo]
86
+ $stack.Push($root)
87
+ while ($stack.Count -gt 0) {
88
+ $dir = $stack.Pop()
89
+ $entries = $null
90
+ try { $entries = $dir.EnumerateFileSystemInfos() } catch { $errors++; continue }
91
+ try {
92
+ foreach ($e in $entries) {
93
+ if (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $links++; continue }
94
+ if (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0) { $dirs++; $stack.Push([IO.DirectoryInfo]$e); continue }
95
+ $files++
96
+ $bytes += [long]$e.Length
97
+ $t = $e.LastWriteTimeUtc
98
+ if ($e.LastAccessTimeUtc -gt $t) { $t = $e.LastAccessTimeUtc }
99
+ if ($e.CreationTimeUtc -gt $t) { $t = $e.CreationTimeUtc }
100
+ if ($t -gt $newest) { $newest = $t }
101
+ }
102
+ } catch { $errors++ }
103
+ }
104
+ if ($newest -eq [datetime]::MinValue) { $newest = Get-NewestTimestampUtc $root }
105
+ return [pscustomobject]@{ Bytes = $bytes; Files = $files; Dirs = $dirs; Links = $links; Newest = $newest; Errors = $errors; Exists = $true }
106
+ }
107
+
108
+ function Get-DirectoryBytes {
109
+ <# .SYNOPSIS Total bytes under a path (0 when absent). Uses robocopy /L when available - it is native and fast. #>
110
+ param([string] $Path)
111
+ if (-not (Test-PathPresent $Path)) { return [long]0 }
112
+ $info = Get-ItemInfo $Path
113
+ if ($info -is [IO.FileInfo]) { return [long]$info.Length }
114
+ if (Test-ReparsePoint $info) { return [long]0 }
115
+ if (Get-Command robocopy.exe -ErrorAction SilentlyContinue) {
116
+ try {
117
+ $sink = Join-Path $env:TEMP ('windowsweep-null-' + [guid]::NewGuid().ToString('N'))
118
+ $lines = & robocopy.exe $Path $sink /L /S /NJH /BYTES /NFL /NDL /NC /NS /XJ /R:0 /W:0 2>$null
119
+ foreach ($line in $lines) {
120
+ if ($line -match '^\s*Bytes\s*:\s*(\d+)') { return [long]$Matches[1] }
121
+ }
122
+ } catch { $null = $_ }
123
+ }
124
+ return [long](Get-DirectoryStats $Path).Bytes
125
+ }
126
+
127
+ function Get-IdleDays {
128
+ <# .SYNOPSIS Days since the path (or anything inside it) was last touched. 999999 when absent. #>
129
+ param([string] $Path)
130
+ $info = Get-ItemInfo $Path
131
+ if ($null -eq $info) { return 999999 }
132
+ if ($info -is [IO.FileInfo] -or (Test-ReparsePoint $info)) {
133
+ return [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $info)).TotalDays)
134
+ }
135
+ $stats = Get-DirectoryStats $Path
136
+ return [int][math]::Floor(([datetime]::UtcNow - $stats.Newest).TotalDays)
137
+ }
138
+
139
+ function Get-StaleFiles {
140
+ <# .SYNOPSIS Files under Root whose newest timestamp is at least Days old; never follows reparse points.
141
+ Returns objects with Path, Bytes. Also returns the list of directories visited (for the empty sweep). #>
142
+ param([string] $Root, [int] $Days)
143
+ $cutoff = [datetime]::UtcNow.AddDays(-1 * $Days)
144
+ $stale = New-Object System.Collections.Generic.List[object]
145
+ $visited = New-Object System.Collections.Generic.List[string]
146
+ $rootInfo = Get-ItemInfo $Root
147
+ if ($null -eq $rootInfo -or -not ($rootInfo -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $rootInfo)) {
148
+ return [pscustomobject]@{ Files = $stale; Dirs = $visited }
149
+ }
150
+ $stack = New-Object System.Collections.Generic.Stack[System.IO.DirectoryInfo]
151
+ $stack.Push($rootInfo)
152
+ while ($stack.Count -gt 0) {
153
+ $dir = $stack.Pop()
154
+ $entries = $null
155
+ try { $entries = $dir.EnumerateFileSystemInfos() } catch { continue }
156
+ try {
157
+ foreach ($e in $entries) {
158
+ if (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
159
+ if (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0) {
160
+ $visited.Add($e.FullName)
161
+ $stack.Push([IO.DirectoryInfo]$e)
162
+ continue
163
+ }
164
+ $t = $e.LastWriteTimeUtc
165
+ if ($e.LastAccessTimeUtc -gt $t) { $t = $e.LastAccessTimeUtc }
166
+ if ($e.CreationTimeUtc -gt $t) { $t = $e.CreationTimeUtc }
167
+ if ($t -le $cutoff) { $stale.Add([pscustomobject]@{ Path = $e.FullName; Bytes = [long]$e.Length }) }
168
+ }
169
+ } catch { $null = $_ }
170
+ }
171
+ return [pscustomobject]@{ Files = $stale; Dirs = $visited }
172
+ }
173
+
174
+ function Remove-LongPrefix {
175
+ <# .SYNOPSIS Strip \\?\ for display. #>
176
+ param([string] $Path)
177
+ if ($Path.StartsWith('\\?\UNC\')) { return ('\\' + $Path.Substring(8)) }
178
+ if ($Path.StartsWith('\\?\')) { return $Path.Substring(4) }
179
+ return $Path
180
+ }