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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +482 -0
- package/SECURITY.md +46 -0
- package/VERSION +1 -0
- package/bin/windowsweep.js +83 -0
- package/lib/actions.ps1 +209 -0
- package/lib/config.ps1 +90 -0
- package/lib/constants.ps1 +89 -0
- package/lib/fs.ps1 +180 -0
- package/lib/log.ps1 +140 -0
- package/lib/safety.ps1 +493 -0
- package/lib/scan.ps1 +94 -0
- package/lib/ui.ps1 +232 -0
- package/modules/android_avd.ps1 +36 -0
- package/modules/app_caches.ps1 +58 -0
- package/modules/browsers.ps1 +33 -0
- package/modules/build_tools.ps1 +35 -0
- package/modules/crash_trap.ps1 +89 -0
- package/modules/disk_usage.ps1 +49 -0
- package/modules/docker.ps1 +93 -0
- package/modules/editors.ps1 +149 -0
- package/modules/health.ps1 +101 -0
- package/modules/menu.ps1 +61 -0
- package/modules/personal.ps1 +124 -0
- package/modules/pkg_managers.ps1 +69 -0
- package/modules/projects.ps1 +140 -0
- package/modules/release_helpers.ps1 +414 -0
- package/modules/reports.ps1 +212 -0
- package/modules/runner.ps1 +190 -0
- package/modules/system_admin.ps1 +220 -0
- package/modules/test_runners.ps1 +24 -0
- package/modules/walkthrough.ps1 +79 -0
- package/modules/windows_user.ps1 +112 -0
- package/package.json +73 -0
- package/windowsweep.cmd +6 -0
- package/windowsweep.ps1 +292 -0
|
@@ -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
|
+
});
|
package/lib/actions.ps1
ADDED
|
@@ -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
|
+
}
|