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.
- package/AI-INTEGRATION-GUIDE.md +203 -0
- package/CHANGELOG.md +124 -0
- package/LICENSE +21 -21
- package/README.md +88 -49
- package/VERSION +1 -1
- package/lib/actions.ps1 +212 -209
- package/lib/config.ps1 +108 -99
- package/lib/constants.ps1 +103 -89
- package/lib/fs.ps1 +189 -180
- package/lib/log.ps1 +140 -140
- package/lib/safety.ps1 +541 -493
- package/lib/scan.ps1 +109 -94
- package/lib/ui.ps1 +275 -235
- package/modules/android_avd.ps1 +36 -36
- package/modules/app_caches.ps1 +58 -58
- package/modules/browsers.ps1 +33 -33
- package/modules/build_tools.ps1 +35 -35
- package/modules/crash_trap.ps1 +89 -89
- package/modules/disk_usage.ps1 +49 -49
- package/modules/docker.ps1 +93 -93
- package/modules/editors.ps1 +150 -150
- package/modules/globals_audit.ps1 +236 -0
- package/modules/health.ps1 +101 -101
- package/modules/installed_programs.ps1 +118 -0
- package/modules/menu.ps1 +63 -61
- package/modules/notify.ps1 +60 -0
- package/modules/orphaned_appdata.ps1 +189 -0
- package/modules/personal.ps1 +126 -124
- package/modules/pkg_managers.ps1 +73 -69
- package/modules/projects.ps1 +160 -143
- package/modules/release_helpers.ps1 +438 -434
- package/modules/reports.ps1 +212 -212
- package/modules/runner.ps1 +242 -190
- package/modules/self_test_extra.ps1 +537 -86
- package/modules/startup_audit.ps1 +126 -0
- package/modules/system_admin.ps1 +220 -220
- package/modules/test_runners.ps1 +24 -24
- package/modules/walkthrough.ps1 +80 -79
- package/modules/windows_user.ps1 +118 -112
- package/package.json +4 -3
- package/windowsweep.ps1 +322 -296
package/lib/safety.ps1
CHANGED
|
@@ -1,493 +1,541 @@
|
|
|
1
|
-
# safety.ps1 - the deletion chokepoint and its guards.
|
|
2
|
-
#
|
|
3
|
-
# Every byte this tool removes passes through Remove-PathSafe (or Send-ToRecycleBin), which:
|
|
4
|
-
# 1. resolves the path and refuses relative segments, UNC paths, drive roots and system roots;
|
|
5
|
-
# 2. refuses every protected subtree / pattern / basename (Test-ProtectedPath);
|
|
6
|
-
# 3. asserts the path lies strictly inside the target root the caller declared (-Within);
|
|
7
|
-
# 4. short-circuits in --dry-run, printing what it would do and tallying an estimate;
|
|
8
|
-
# 5. removes reparse points as links, never following them; skips locked files, never fails a run.
|
|
9
|
-
# No flag bypasses steps 1-3.
|
|
10
|
-
|
|
11
|
-
function Initialize-Safety {
|
|
12
|
-
<# .SYNOPSIS Build the protected-path tables from the environment. Called once at startup. #>
|
|
13
|
-
$U = Get-FullPath $env:USERPROFILE
|
|
14
|
-
$L = Get-FullPath $env:LOCALAPPDATA
|
|
15
|
-
$A = Get-FullPath $env:APPDATA
|
|
16
|
-
$LL = Get-FullPath (Join-Path (Split-Path -Parent $L) 'LocalLow')
|
|
17
|
-
$SR = Get-FullPath $env:SystemRoot
|
|
18
|
-
$PD = Get-FullPath $env:ProgramData
|
|
19
|
-
$SD = $env:SystemDrive
|
|
20
|
-
$PF = Get-FullPath $env:ProgramFiles
|
|
21
|
-
$PF86 = Get-FullPath ${env:ProgramFiles(x86)}
|
|
22
|
-
|
|
23
|
-
$exact = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
24
|
-
foreach ($d in [IO.DriveInfo]::GetDrives()) { $null = $exact.Add($d.Name.TrimEnd('\')); $null = $exact.Add($d.Name) }
|
|
25
|
-
foreach ($p in @($SR, "$SR\System32", "$SR\SysWOW64", $PF, $PF86, $PD, "$SD\Users", "$SD\Users\Default", "$SD\Users\Public", $env:PUBLIC, $U, "$U\AppData", $A, $L, $LL)) {
|
|
26
|
-
if (-not $p) { continue }
|
|
27
|
-
$fp = Get-FullPath $p
|
|
28
|
-
if ($fp) { $null = $exact.Add($fp) }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
$subtrees = @(
|
|
32
|
-
"$U\Documents", "$U\Pictures", "$U\Music", "$U\Videos", "$U\Desktop", "$U\Contacts", "$U\Favorites", "$U\Links",
|
|
33
|
-
"$U\Saved Games", "$U\Searches", "$U\3D Objects", "$U\Dropbox", "$U\Google Drive", "$U\iCloudDrive",
|
|
34
|
-
"$U\.ssh", "$U\.gnupg", "$U\.secrets", "$U\.aws", "$U\.azure", "$U\.kube", "$U\.gcloud", "$U\.docker", "$U\.config",
|
|
35
|
-
"$U\.local", "$U\.claude", "$U\.codex", "$U\.agents", "$U\.gemini", "$U\.copilot", "$U\.antigravity", "$U\.ollama",
|
|
36
|
-
"$U\.vscode-server", "$U\.cursor-server", "$U\.password-store",
|
|
37
|
-
"$A\npm", "$A\nvm", "$L\nvm", "$SD\nvm4w", "$L\Volta", "$L\fnm", "$L\fnm_multishells", "$L\node\corepack",
|
|
38
|
-
"$L\pnpm\global", "$U\.bun\bin", "$U\.bun\install\global", "$U\.deno\bin", "$U\.cargo\bin", "$U\.rustup", "$U\go\bin",
|
|
39
|
-
"$L\Programs", "$L\Microsoft\WindowsApps", "$L\Android\Sdk",
|
|
40
|
-
"$SR\Prefetch", "$SR\Installer", "$SR\WinSxS", "$SR\System32\config", "$SR\servicing", "$SR\Boot", "$SR\Fonts",
|
|
41
|
-
"$PD\Package Cache", "$PD\Microsoft\Windows\Start Menu",
|
|
42
|
-
"$SD\`$Recycle.Bin", "$SD\System Volume Information", "$SD\Recovery", "$SD\Boot", "$SD\EFI"
|
|
43
|
-
)
|
|
44
|
-
# Declared exceptions: paths inside a protected subtree that ARE regenerable caches.
|
|
45
|
-
$exceptions = @("$L\Android\Sdk\.temp", "$L\Android\Sdk\.downloadIntermediates")
|
|
46
|
-
|
|
47
|
-
# Wildcards matched against the full path. Browser/editor state that must never be mistaken for cache.
|
|
48
|
-
$patterns = @(
|
|
49
|
-
"$U\OneDrive*", "*\Packages\*\LocalState*", "*\Packages\*\Settings*", "*\Packages\*\RoamingState*",
|
|
50
|
-
'*chrome-for-testing*', '*\Chrome for Testing*',
|
|
51
|
-
'*\User Data\Default', '*\User Data\Guest Profile', '*\User Data\System Profile', '*\User Data\Profile ?', '*\User Data\Profile ??',
|
|
52
|
-
'*\User Data\*\Local Storage*', '*\User Data\*\Session Storage*', '*\User Data\*\IndexedDB*', '*\User Data\*\Cookies*',
|
|
53
|
-
'*\User Data\*\Login Data*', '*\User Data\*\Web Data*', '*\User Data\*\Bookmarks*', '*\User Data\*\History*',
|
|
54
|
-
'*\User Data\*\Sync Data*', '*\User Data\*\Preferences', '*\User Data\*\Secure Preferences', '*\User Data\*\Network\*',
|
|
55
|
-
'*\User Data\*\File System*', '*\User Data\*\databases*', '*\User Data\*\Sessions*', '*\User Data\*\Extension State*',
|
|
56
|
-
'*\User Data\*\Local Extension Settings*', '*\User Data\*\Sync Extension Settings*', '*\User Data\*\Extensions\*',
|
|
57
|
-
'*\User Data\*\Service Worker\Database*', '*\User Data\*\Service Worker\CacheStorage*', '*\User Data\*\shared_proto_db*',
|
|
58
|
-
'*\User\globalStorage*', '*\User\History*', '*\User\settings.json', '*\User\keybindings.json', '*\User\snippets*',
|
|
59
|
-
"$U\.cache\claude*", "$U\.cache\codex*", "$U\.cache\gemini*", "$U\.cache\copilot*", "$L\JetBrains\Toolbox*",
|
|
60
|
-
'*\Profiles\*\places.sqlite*', '*\Profiles\*\key4.db', '*\Profiles\*\logins.json', '*\Profiles\*\cookies.sqlite*',
|
|
61
|
-
'*\Profiles\*\storage\*', '*\Profiles\*\prefs.js', '*\Profiles\*\extensions\*'
|
|
62
|
-
)
|
|
63
|
-
$basenames = @('NTUSER.DAT', 'NTUSER.DAT.LOG1', 'NTUSER.DAT.LOG2', 'ntuser.ini', 'UsrClass.dat', 'UsrClass.dat.LOG1',
|
|
64
|
-
'UsrClass.dat.LOG2', 'hiberfil.sys', 'pagefile.sys', 'swapfile.sys', 'bootmgr', 'BOOTNXT', 'DumpStack.log.tmp')
|
|
65
|
-
|
|
66
|
-
# Pre-normalized once: Get-ProtectionReason runs once per file during a prune (400k+ files on a big yarn
|
|
67
|
-
# cache), so it must be plain string tests - no per-call path resolution, no per-call wildcard compile.
|
|
68
|
-
$subtreesNorm = @($subtrees | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
|
|
69
|
-
$exceptionsNorm = @($exceptions | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
|
|
70
|
-
$wildcardIgnoreCase = [System.Management.Automation.WildcardOptions]::IgnoreCase
|
|
71
|
-
$Script:WS_PROTECT = @{
|
|
72
|
-
Exact = $exact
|
|
73
|
-
Subtrees = $subtreesNorm
|
|
74
|
-
SubtreePrefixes = @($subtreesNorm | ForEach-Object { $_ + '\' })
|
|
75
|
-
Exceptions = $exceptionsNorm
|
|
76
|
-
ExceptionPrefixes = @($exceptionsNorm | ForEach-Object { $_ + '\' })
|
|
77
|
-
Patterns = $patterns
|
|
78
|
-
PatternObjects = @($patterns | ForEach-Object { [System.Management.Automation.WildcardPattern]::Get($_, $wildcardIgnoreCase) })
|
|
79
|
-
Basenames = $basenames
|
|
80
|
-
HomePrefix = $null
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function Test-PathWithin {
|
|
85
|
-
<# .SYNOPSIS True when Path equals Within or lies inside it (case-insensitive, normalized). #>
|
|
86
|
-
param([string] $Path, [string] $Within)
|
|
87
|
-
$p = Get-FullPath $Path; $w = Get-FullPath $Within
|
|
88
|
-
if (-not $p -or -not $w) { return $false }
|
|
89
|
-
if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
90
|
-
$prefix = $w
|
|
91
|
-
if (-not $prefix.EndsWith('\')) { $prefix = $prefix + '\' }
|
|
92
|
-
return $p.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function
|
|
96
|
-
<# .SYNOPSIS
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
$
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
$
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return $
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if ($
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return $r
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
$
|
|
235
|
-
$
|
|
236
|
-
if (-
|
|
237
|
-
$
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return $out
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
$
|
|
296
|
-
foreach ($
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
$
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
$
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
$
|
|
339
|
-
|
|
340
|
-
$
|
|
341
|
-
if (
|
|
342
|
-
if (
|
|
343
|
-
$
|
|
344
|
-
$
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
$
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
$
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
$
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
return $out
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function
|
|
384
|
-
<# .SYNOPSIS
|
|
385
|
-
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within)
|
|
386
|
-
$
|
|
387
|
-
if ($
|
|
388
|
-
$
|
|
389
|
-
$p
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
$
|
|
396
|
-
if ($
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
Write-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
$
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (
|
|
424
|
-
Write-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
$
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
$
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1
|
+
# safety.ps1 - the deletion chokepoint and its guards.
|
|
2
|
+
#
|
|
3
|
+
# Every byte this tool removes passes through Remove-PathSafe (or Send-ToRecycleBin), which:
|
|
4
|
+
# 1. resolves the path and refuses relative segments, UNC paths, drive roots and system roots;
|
|
5
|
+
# 2. refuses every protected subtree / pattern / basename (Test-ProtectedPath);
|
|
6
|
+
# 3. asserts the path lies strictly inside the target root the caller declared (-Within);
|
|
7
|
+
# 4. short-circuits in --dry-run, printing what it would do and tallying an estimate;
|
|
8
|
+
# 5. removes reparse points as links, never following them; skips locked files, never fails a run.
|
|
9
|
+
# No flag bypasses steps 1-3.
|
|
10
|
+
|
|
11
|
+
function Initialize-Safety {
|
|
12
|
+
<# .SYNOPSIS Build the protected-path tables from the environment. Called once at startup. #>
|
|
13
|
+
$U = Get-FullPath $env:USERPROFILE
|
|
14
|
+
$L = Get-FullPath $env:LOCALAPPDATA
|
|
15
|
+
$A = Get-FullPath $env:APPDATA
|
|
16
|
+
$LL = Get-FullPath (Join-Path (Split-Path -Parent $L) 'LocalLow')
|
|
17
|
+
$SR = Get-FullPath $env:SystemRoot
|
|
18
|
+
$PD = Get-FullPath $env:ProgramData
|
|
19
|
+
$SD = $env:SystemDrive
|
|
20
|
+
$PF = Get-FullPath $env:ProgramFiles
|
|
21
|
+
$PF86 = Get-FullPath ${env:ProgramFiles(x86)}
|
|
22
|
+
|
|
23
|
+
$exact = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
24
|
+
foreach ($d in [IO.DriveInfo]::GetDrives()) { $null = $exact.Add($d.Name.TrimEnd('\')); $null = $exact.Add($d.Name) }
|
|
25
|
+
foreach ($p in @($SR, "$SR\System32", "$SR\SysWOW64", $PF, $PF86, $PD, "$SD\Users", "$SD\Users\Default", "$SD\Users\Public", $env:PUBLIC, $U, "$U\AppData", $A, $L, $LL)) {
|
|
26
|
+
if (-not $p) { continue }
|
|
27
|
+
$fp = Get-FullPath $p
|
|
28
|
+
if ($fp) { $null = $exact.Add($fp) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
$subtrees = @(
|
|
32
|
+
"$U\Documents", "$U\Pictures", "$U\Music", "$U\Videos", "$U\Desktop", "$U\Contacts", "$U\Favorites", "$U\Links",
|
|
33
|
+
"$U\Saved Games", "$U\Searches", "$U\3D Objects", "$U\Dropbox", "$U\Google Drive", "$U\iCloudDrive",
|
|
34
|
+
"$U\.ssh", "$U\.gnupg", "$U\.secrets", "$U\.aws", "$U\.azure", "$U\.kube", "$U\.gcloud", "$U\.docker", "$U\.config",
|
|
35
|
+
"$U\.local", "$U\.claude", "$U\.codex", "$U\.agents", "$U\.gemini", "$U\.copilot", "$U\.antigravity", "$U\.ollama",
|
|
36
|
+
"$U\.vscode-server", "$U\.cursor-server", "$U\.password-store",
|
|
37
|
+
"$A\npm", "$A\nvm", "$L\nvm", "$SD\nvm4w", "$L\Volta", "$L\fnm", "$L\fnm_multishells", "$L\node\corepack",
|
|
38
|
+
"$L\pnpm\global", "$U\.bun\bin", "$U\.bun\install\global", "$U\.deno\bin", "$U\.cargo\bin", "$U\.rustup", "$U\go\bin",
|
|
39
|
+
"$L\Programs", "$L\Microsoft\WindowsApps", "$L\Android\Sdk",
|
|
40
|
+
"$SR\Prefetch", "$SR\Installer", "$SR\WinSxS", "$SR\System32\config", "$SR\servicing", "$SR\Boot", "$SR\Fonts",
|
|
41
|
+
"$PD\Package Cache", "$PD\Microsoft\Windows\Start Menu",
|
|
42
|
+
"$SD\`$Recycle.Bin", "$SD\System Volume Information", "$SD\Recovery", "$SD\Boot", "$SD\EFI"
|
|
43
|
+
)
|
|
44
|
+
# Declared exceptions: paths inside a protected subtree that ARE regenerable caches.
|
|
45
|
+
$exceptions = @("$L\Android\Sdk\.temp", "$L\Android\Sdk\.downloadIntermediates")
|
|
46
|
+
|
|
47
|
+
# Wildcards matched against the full path. Browser/editor state that must never be mistaken for cache.
|
|
48
|
+
$patterns = @(
|
|
49
|
+
"$U\OneDrive*", "*\Packages\*\LocalState*", "*\Packages\*\Settings*", "*\Packages\*\RoamingState*",
|
|
50
|
+
'*chrome-for-testing*', '*\Chrome for Testing*',
|
|
51
|
+
'*\User Data\Default', '*\User Data\Guest Profile', '*\User Data\System Profile', '*\User Data\Profile ?', '*\User Data\Profile ??',
|
|
52
|
+
'*\User Data\*\Local Storage*', '*\User Data\*\Session Storage*', '*\User Data\*\IndexedDB*', '*\User Data\*\Cookies*',
|
|
53
|
+
'*\User Data\*\Login Data*', '*\User Data\*\Web Data*', '*\User Data\*\Bookmarks*', '*\User Data\*\History*',
|
|
54
|
+
'*\User Data\*\Sync Data*', '*\User Data\*\Preferences', '*\User Data\*\Secure Preferences', '*\User Data\*\Network\*',
|
|
55
|
+
'*\User Data\*\File System*', '*\User Data\*\databases*', '*\User Data\*\Sessions*', '*\User Data\*\Extension State*',
|
|
56
|
+
'*\User Data\*\Local Extension Settings*', '*\User Data\*\Sync Extension Settings*', '*\User Data\*\Extensions\*',
|
|
57
|
+
'*\User Data\*\Service Worker\Database*', '*\User Data\*\Service Worker\CacheStorage*', '*\User Data\*\shared_proto_db*',
|
|
58
|
+
'*\User\globalStorage*', '*\User\History*', '*\User\settings.json', '*\User\keybindings.json', '*\User\snippets*',
|
|
59
|
+
"$U\.cache\claude*", "$U\.cache\codex*", "$U\.cache\gemini*", "$U\.cache\copilot*", "$L\JetBrains\Toolbox*",
|
|
60
|
+
'*\Profiles\*\places.sqlite*', '*\Profiles\*\key4.db', '*\Profiles\*\logins.json', '*\Profiles\*\cookies.sqlite*',
|
|
61
|
+
'*\Profiles\*\storage\*', '*\Profiles\*\prefs.js', '*\Profiles\*\extensions\*'
|
|
62
|
+
)
|
|
63
|
+
$basenames = @('NTUSER.DAT', 'NTUSER.DAT.LOG1', 'NTUSER.DAT.LOG2', 'ntuser.ini', 'UsrClass.dat', 'UsrClass.dat.LOG1',
|
|
64
|
+
'UsrClass.dat.LOG2', 'hiberfil.sys', 'pagefile.sys', 'swapfile.sys', 'bootmgr', 'BOOTNXT', 'DumpStack.log.tmp')
|
|
65
|
+
|
|
66
|
+
# Pre-normalized once: Get-ProtectionReason runs once per file during a prune (400k+ files on a big yarn
|
|
67
|
+
# cache), so it must be plain string tests - no per-call path resolution, no per-call wildcard compile.
|
|
68
|
+
$subtreesNorm = @($subtrees | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
|
|
69
|
+
$exceptionsNorm = @($exceptions | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
|
|
70
|
+
$wildcardIgnoreCase = [System.Management.Automation.WildcardOptions]::IgnoreCase
|
|
71
|
+
$Script:WS_PROTECT = @{
|
|
72
|
+
Exact = $exact
|
|
73
|
+
Subtrees = $subtreesNorm
|
|
74
|
+
SubtreePrefixes = @($subtreesNorm | ForEach-Object { $_ + '\' })
|
|
75
|
+
Exceptions = $exceptionsNorm
|
|
76
|
+
ExceptionPrefixes = @($exceptionsNorm | ForEach-Object { $_ + '\' })
|
|
77
|
+
Patterns = $patterns
|
|
78
|
+
PatternObjects = @($patterns | ForEach-Object { [System.Management.Automation.WildcardPattern]::Get($_, $wildcardIgnoreCase) })
|
|
79
|
+
Basenames = $basenames
|
|
80
|
+
HomePrefix = $null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Test-PathWithin {
|
|
85
|
+
<# .SYNOPSIS True when Path equals Within or lies inside it (case-insensitive, normalized). #>
|
|
86
|
+
param([string] $Path, [string] $Within)
|
|
87
|
+
$p = Get-FullPath $Path; $w = Get-FullPath $Within
|
|
88
|
+
if (-not $p -or -not $w) { return $false }
|
|
89
|
+
if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
90
|
+
$prefix = $w
|
|
91
|
+
if (-not $prefix.EndsWith('\')) { $prefix = $prefix + '\' }
|
|
92
|
+
return $p.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function Initialize-Exclusions {
|
|
96
|
+
<# .SYNOPSIS Normalise --exclude-path (and config excludePaths) into prefixes Get-ProtectionReason can
|
|
97
|
+
test with one StartsWith. Called from Initialize-Settings and NOT from Initialize-Safety: the config
|
|
98
|
+
file is merged AFTER the safety tables are built, so building this table earlier would silently drop
|
|
99
|
+
every config-file exclusion while the flag ones kept working. #>
|
|
100
|
+
$ws = $Script:WS
|
|
101
|
+
$Script:WS_EXCLUDE = @{ Prefixes = @(); Roots = @() }
|
|
102
|
+
foreach ($x in @($ws.ExcludePaths)) {
|
|
103
|
+
if ([string]::IsNullOrWhiteSpace($x)) { continue }
|
|
104
|
+
$fp = Get-FullPath $x
|
|
105
|
+
if (-not $fp) { continue }
|
|
106
|
+
$Script:WS_EXCLUDE.Roots += $fp
|
|
107
|
+
$Script:WS_EXCLUDE.Prefixes += ($fp.TrimEnd('\') + '\')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Get-ProtectionReason {
|
|
112
|
+
<# .SYNOPSIS Why a path may not be deleted, or $null when it is allowed.
|
|
113
|
+
Hot path: called once per file during a prune, so it uses only the tables Initialize-Safety pre-normalized.
|
|
114
|
+
"$p\" starts with "$root\" is exactly Test-PathWithin (equal, or inside) without the per-call resolution. #>
|
|
115
|
+
param([string] $Path)
|
|
116
|
+
if ([string]::IsNullOrWhiteSpace($Path)) { return 'empty path' }
|
|
117
|
+
$raw = $Path.Trim()
|
|
118
|
+
if ($raw -match '(^|[\\/])\.\.([\\/]|$)') { return 'relative ".." segment' }
|
|
119
|
+
if ($raw.StartsWith('\\') -and -not $raw.StartsWith('\\?\')) { return 'UNC path' }
|
|
120
|
+
$p = Get-FullPath $raw
|
|
121
|
+
if (-not $p) { return 'unresolvable path' }
|
|
122
|
+
if ($p.StartsWith('\\')) { return 'UNC path' }
|
|
123
|
+
if ($p -match '^[A-Za-z]:\\?$') { return 'drive root' }
|
|
124
|
+
$pr = $Script:WS_PROTECT
|
|
125
|
+
if ($pr.Exact.Contains($p)) { return "protected root: $p" }
|
|
126
|
+
$ic = [StringComparison]::OrdinalIgnoreCase
|
|
127
|
+
$pSlash = $p + '\'
|
|
128
|
+
foreach ($e in $pr.ExceptionPrefixes) { if ($pSlash.StartsWith($e, $ic)) { return $null } }
|
|
129
|
+
for ($i = 0; $i -lt $pr.SubtreePrefixes.Count; $i++) {
|
|
130
|
+
if ($pSlash.StartsWith($pr.SubtreePrefixes[$i], $ic)) { return "inside protected: $($pr.Subtrees[$i])" }
|
|
131
|
+
}
|
|
132
|
+
for ($i = 0; $i -lt $pr.PatternObjects.Count; $i++) {
|
|
133
|
+
if ($pr.PatternObjects[$i].IsMatch($p)) { return "matches protected pattern: $($pr.Patterns[$i])" }
|
|
134
|
+
}
|
|
135
|
+
$base = [IO.Path]::GetFileName($p)
|
|
136
|
+
foreach ($b in $pr.Basenames) { if ($base.Equals($b, $ic)) { return "protected file name: $b" } }
|
|
137
|
+
if ($Script:WS.Home -and -not $Script:WS.AllowOwnData) {
|
|
138
|
+
if (-not $pr.HomePrefix) { $pr.HomePrefix = (Get-FullPath $Script:WS.Home) + '\' }
|
|
139
|
+
if ($pSlash.StartsWith($pr.HomePrefix, $ic)) { return 'the tool''s own data directory' }
|
|
140
|
+
}
|
|
141
|
+
# A user exclusion is checked LAST, so a path that is both protected and excluded still reports the
|
|
142
|
+
# PROTECTED reason - the stronger statement, and the one no flag can lift. When nothing is excluded the
|
|
143
|
+
# loop runs zero times, which is what makes this safe on a hot path called once per file during a prune.
|
|
144
|
+
if ($Script:WS_EXCLUDE -and $Script:WS_EXCLUDE.Prefixes.Count -gt 0) {
|
|
145
|
+
for ($i = 0; $i -lt $Script:WS_EXCLUDE.Prefixes.Count; $i++) {
|
|
146
|
+
if ($pSlash.StartsWith($Script:WS_EXCLUDE.Prefixes[$i], $ic) -or $p.Equals($Script:WS_EXCLUDE.Roots[$i], $ic)) {
|
|
147
|
+
return "excluded: $($Script:WS_EXCLUDE.Roots[$i])"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return $null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function Test-ProtectedPath {
|
|
155
|
+
param([string] $Path)
|
|
156
|
+
return ($null -ne (Get-ProtectionReason $Path))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function New-RemoveResult {
|
|
160
|
+
return [pscustomobject]@{ Removed = $false; Bytes = [long]0; Files = 0; Skipped = 0; SkippedBytes = [long]0; Reason = $null }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Remove-ReadOnlyAttribute {
|
|
164
|
+
param([string] $LongPath)
|
|
165
|
+
try {
|
|
166
|
+
$a = [IO.File]::GetAttributes($LongPath)
|
|
167
|
+
if (($a -band [IO.FileAttributes]::ReadOnly) -ne 0) { [IO.File]::SetAttributes($LongPath, ($a -bxor [IO.FileAttributes]::ReadOnly)) }
|
|
168
|
+
} catch { $null = $_ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function Remove-TreeInternal {
|
|
172
|
+
<# .SYNOPSIS Recursive delete that removes nested reparse points as links and skips locked files. Internal: guards already ran. #>
|
|
173
|
+
param([string] $Path, [pscustomobject] $Result)
|
|
174
|
+
$lp = Get-LongPath $Path
|
|
175
|
+
$entries = @()
|
|
176
|
+
try { $entries = (New-Object IO.DirectoryInfo($lp)).EnumerateFileSystemInfos() } catch { $Result.Skipped++; return $false }
|
|
177
|
+
$allClear = $true
|
|
178
|
+
foreach ($e in $entries) {
|
|
179
|
+
$isLink = (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
|
|
180
|
+
$isDir = (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0)
|
|
181
|
+
try {
|
|
182
|
+
if ($isLink) {
|
|
183
|
+
if ($isDir) { [IO.Directory]::Delete($e.FullName, $false) } else { [IO.File]::Delete($e.FullName) }
|
|
184
|
+
Write-LogLine ("removed link (not followed): " + (Remove-LongPrefix $e.FullName))
|
|
185
|
+
} elseif ($isDir) {
|
|
186
|
+
if (-not (Remove-TreeInternal -Path $e.FullName -Result $Result)) { $allClear = $false }
|
|
187
|
+
} else {
|
|
188
|
+
Remove-ReadOnlyAttribute $e.FullName
|
|
189
|
+
[IO.File]::Delete($e.FullName)
|
|
190
|
+
$Result.Files++
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
$allClear = $false
|
|
194
|
+
$Result.Skipped++
|
|
195
|
+
try { if (-not $isDir) { $Result.SkippedBytes += [long]$e.Length } } catch { $null = $_ }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if ($allClear) {
|
|
199
|
+
try { [IO.Directory]::Delete($lp, $false) } catch { $allClear = $false; $Result.Skipped++ }
|
|
200
|
+
}
|
|
201
|
+
return $allClear
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function Add-ExcludedRefusal {
|
|
205
|
+
<# .SYNOPSIS Record a user exclusion once, for --json `excluded` and the run summary. Deliberately
|
|
206
|
+
narrow: only a reason starting "excluded: " is recorded, so a PROTECTED-path refusal - the tool
|
|
207
|
+
refusing on its own account rather than on the user's - never lands in the user-exclusion list. #>
|
|
208
|
+
param([string] $Reason)
|
|
209
|
+
if (-not $Reason -or -not $Reason.StartsWith('excluded: ')) { return }
|
|
210
|
+
$ws = $Script:WS
|
|
211
|
+
$path = $Reason.Substring(10)
|
|
212
|
+
if ($ws.Excluded -notcontains $path) { $ws.Excluded += $path }
|
|
213
|
+
if ($ws.Refusals -notcontains $Reason) { $ws.Refusals += $Reason }
|
|
214
|
+
Write-LogLine "excluded: $path"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function Remove-PathSafe {
|
|
218
|
+
<# .SYNOPSIS The chokepoint. Removes one file, directory or link after every guard passes. Honours --dry-run. #>
|
|
219
|
+
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [string] $Label = '')
|
|
220
|
+
$ws = $Script:WS
|
|
221
|
+
$r = New-RemoveResult
|
|
222
|
+
$tag = ''
|
|
223
|
+
if ($Label) { $tag = " [$Label]" }
|
|
224
|
+
$p = Get-FullPath $Path
|
|
225
|
+
$w = Get-FullPath $Within
|
|
226
|
+
if (-not $p -or -not $w) { $r.Reason = 'unresolvable path'; Write-Err "REFUSE ($($r.Reason)): $Path"; return $r }
|
|
227
|
+
$why = Get-ProtectionReason $p
|
|
228
|
+
if ($why) { $r.Reason = $why; Add-ExcludedRefusal $why; Write-Err "REFUSE ($why): $p"; return $r }
|
|
229
|
+
if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { $r.Reason = 'target is its own declared root'; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
|
|
230
|
+
if (-not (Test-PathWithin -Path $p -Within $w)) { $r.Reason = "outside declared root $w"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
|
|
231
|
+
$info = Get-ItemInfo $p
|
|
232
|
+
if ($null -eq $info) { $r.Removed = $true; return $r }
|
|
233
|
+
$isDir = ($info -is [IO.DirectoryInfo])
|
|
234
|
+
$isLink = Test-ReparsePoint $info
|
|
235
|
+
$bytes = [long]0
|
|
236
|
+
if ($isLink) { $bytes = 0 } elseif ($isDir) { $bytes = (Get-DirectoryStats $p).Bytes } else { $bytes = [long]$info.Length }
|
|
237
|
+
if ($ws.DryRun) {
|
|
238
|
+
Write-DryRun ("would remove {0,10} {1}" -f (Format-Bytes $bytes), $p)
|
|
239
|
+
$r.Removed = $true; $r.Bytes = $bytes
|
|
240
|
+
Add-Freed $bytes
|
|
241
|
+
return $r
|
|
242
|
+
}
|
|
243
|
+
$lp = Get-LongPath $p
|
|
244
|
+
try {
|
|
245
|
+
if ($isLink) {
|
|
246
|
+
if ($isDir) { [IO.Directory]::Delete($lp, $false) } else { [IO.File]::Delete($lp) }
|
|
247
|
+
$r.Removed = $true
|
|
248
|
+
Write-LogLine "removed link (not followed): $p$tag"
|
|
249
|
+
} elseif ($isDir) {
|
|
250
|
+
$r.Removed = Remove-TreeInternal -Path $p -Result $r
|
|
251
|
+
$r.Bytes = [math]::Max([long]0, $bytes - $r.SkippedBytes)
|
|
252
|
+
if ($r.Skipped -gt 0) { Write-Note "$($r.Skipped) item(s) in use or inaccessible, left in place under $p" }
|
|
253
|
+
Write-LogLine ("removed dir: $p ($($r.Bytes) bytes, skipped $($r.Skipped))$tag")
|
|
254
|
+
} else {
|
|
255
|
+
Remove-ReadOnlyAttribute $lp
|
|
256
|
+
[IO.File]::Delete($lp)
|
|
257
|
+
$r.Removed = $true; $r.Bytes = $bytes; $r.Files = 1
|
|
258
|
+
Write-LogLine "removed file: $p ($bytes bytes)$tag"
|
|
259
|
+
}
|
|
260
|
+
Add-Freed $r.Bytes
|
|
261
|
+
} catch {
|
|
262
|
+
$r.Skipped++
|
|
263
|
+
$r.Reason = 'in use or access denied'
|
|
264
|
+
Write-Note "in use or inaccessible, skipped: $p"
|
|
265
|
+
Write-LogLine "skip (locked): $p - $($_.Exception.Message)"
|
|
266
|
+
}
|
|
267
|
+
return $r
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function Remove-StaleFiles {
|
|
271
|
+
<# .SYNOPSIS Prune files idle >= Days under Root (both timestamps rule), then sweep empty directories. #>
|
|
272
|
+
param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days, [string] $Label = '')
|
|
273
|
+
$ws = $Script:WS
|
|
274
|
+
$out = [pscustomobject]@{ Freed = [long]0; Files = 0; Skipped = 0; Exists = $false }
|
|
275
|
+
if (-not $Label) { $Label = $Root }
|
|
276
|
+
$root = Get-FullPath $Root
|
|
277
|
+
if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
|
|
278
|
+
if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
|
|
279
|
+
$out.Exists = $true
|
|
280
|
+
$why = Get-ProtectionReason $root
|
|
281
|
+
if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
|
|
282
|
+
if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
|
|
283
|
+
$scan = Get-StaleFiles -Root $root -Days $Days
|
|
284
|
+
# The guard runs ONCE, here, and both the rehearsal and the run read its result. It used to run only
|
|
285
|
+
# in the real-run loop below, so a dry-run counted protected and excluded files the real run then
|
|
286
|
+
# skipped - the estimate was higher than the run, for the one kind of file a person most wants the
|
|
287
|
+
# two to agree about. "The rehearsal is the same command as the performance, minus one word" is the
|
|
288
|
+
# product's promise; it is only true if the same filter decides both.
|
|
289
|
+
$keep = New-Object System.Collections.Generic.List[object]
|
|
290
|
+
foreach ($f in $scan.Files) {
|
|
291
|
+
$why = Get-ProtectionReason $f.Path
|
|
292
|
+
if ($why) { Add-ExcludedRefusal $why; $out.Skipped++; continue }
|
|
293
|
+
$keep.Add($f)
|
|
294
|
+
}
|
|
295
|
+
$total = [long]0
|
|
296
|
+
foreach ($f in $keep) { $total += $f.Bytes }
|
|
297
|
+
if ($keep.Count -eq 0) { Write-Info "$Label - nothing idle for $Days+ days"; return $out }
|
|
298
|
+
if ($ws.DryRun) {
|
|
299
|
+
Write-DryRun ("would prune {0} in {1} files idle {2}+ days from {3}" -f (Format-Bytes $total), $keep.Count, $Days, $root)
|
|
300
|
+
$out.Freed = $total; $out.Files = $keep.Count
|
|
301
|
+
Add-Freed $total
|
|
302
|
+
return $out
|
|
303
|
+
}
|
|
304
|
+
foreach ($f in $keep) {
|
|
305
|
+
try {
|
|
306
|
+
Remove-ReadOnlyAttribute $f.Path
|
|
307
|
+
[IO.File]::Delete($f.Path)
|
|
308
|
+
$out.Freed += $f.Bytes; $out.Files++
|
|
309
|
+
} catch { $out.Skipped++ }
|
|
310
|
+
}
|
|
311
|
+
# Sweep directories that became empty, deepest first; never the root itself.
|
|
312
|
+
$dirs = @($scan.Dirs | Sort-Object { $_.Length } -Descending)
|
|
313
|
+
foreach ($d in $dirs) {
|
|
314
|
+
try {
|
|
315
|
+
$di = New-Object IO.DirectoryInfo($d)
|
|
316
|
+
if (($di.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
|
|
317
|
+
$any = $false
|
|
318
|
+
foreach ($x in $di.EnumerateFileSystemInfos()) { $any = $true; break }
|
|
319
|
+
if (-not $any) { [IO.Directory]::Delete($d, $false) }
|
|
320
|
+
} catch { $null = $_ }
|
|
321
|
+
}
|
|
322
|
+
Add-Freed $out.Freed
|
|
323
|
+
Write-LogLine ("pruned $($out.Files) files ($($out.Freed) bytes) idle $Days+ days from $root; skipped $($out.Skipped)")
|
|
324
|
+
Write-Ok ("$Label - pruned {0} in {1} files idle {2}+ days ({3} skipped in use)" -f (Format-Bytes $out.Freed), $out.Files, $Days, $out.Skipped)
|
|
325
|
+
return $out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function Remove-StaleUnits {
|
|
329
|
+
<# .SYNOPSIS Treat each top-level child as one unit (a tool version, an AVD). Delete units idle >= Days;
|
|
330
|
+
-KeepNewest never removes the freshest unit of each group (-GroupBy maps a name to its group; default: one group). #>
|
|
331
|
+
param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days,
|
|
332
|
+
[string] $Pattern = '*', [switch] $KeepNewest, [switch] $DirectoriesOnly, [scriptblock] $GroupBy = $null,
|
|
333
|
+
[string[]] $ExcludeNames = @(), [string] $Label = '')
|
|
334
|
+
$out = [pscustomobject]@{ Inspected = 0; Pruned = 0; Kept = 0; Freed = [long]0; Exists = $false }
|
|
335
|
+
if (-not $Label) { $Label = $Root }
|
|
336
|
+
$root = Get-FullPath $Root
|
|
337
|
+
if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
|
|
338
|
+
if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
|
|
339
|
+
$out.Exists = $true
|
|
340
|
+
$why = Get-ProtectionReason $root
|
|
341
|
+
if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
|
|
342
|
+
if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
|
|
343
|
+
$units = @()
|
|
344
|
+
foreach ($e in (Get-ChildEntries $root)) {
|
|
345
|
+
if ($DirectoriesOnly -and -not ($e -is [IO.DirectoryInfo])) { continue }
|
|
346
|
+
if ($e.Name -notlike $Pattern) { continue }
|
|
347
|
+
if ($ExcludeNames -contains $e.Name) { continue }
|
|
348
|
+
if (Test-ReparsePoint $e) { continue }
|
|
349
|
+
$full = Remove-LongPrefix $e.FullName
|
|
350
|
+
$group = 'all'
|
|
351
|
+
if ($GroupBy) { $group = [string](& $GroupBy $e.Name) }
|
|
352
|
+
$units += [pscustomobject]@{ Name = $e.Name; Path = $full; Idle = (Get-IdleDays $full); IsDir = ($e -is [IO.DirectoryInfo]); Group = $group }
|
|
353
|
+
}
|
|
354
|
+
if ($units.Count -eq 0) { Write-Info "$Label - nothing to inspect"; return $out }
|
|
355
|
+
$keep = @{}
|
|
356
|
+
if ($KeepNewest) {
|
|
357
|
+
foreach ($g in ($units | Group-Object Group)) {
|
|
358
|
+
$freshest = $g.Group | Sort-Object Idle | Select-Object -First 1
|
|
359
|
+
$keep[$freshest.Path] = $true
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
foreach ($u in ($units | Sort-Object Name)) {
|
|
363
|
+
$out.Inspected++
|
|
364
|
+
if ($KeepNewest -and $keep.ContainsKey($u.Path)) {
|
|
365
|
+
Write-Note ("kept {0} (newest of its kind, {1}d idle)" -f $u.Name, $u.Idle)
|
|
366
|
+
$out.Kept++
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
if ($u.Idle -ge $Days) {
|
|
370
|
+
$r = Remove-PathSafe -Path $u.Path -Within $Within -Label $u.Name
|
|
371
|
+
if ($r.Removed) {
|
|
372
|
+
$out.Pruned++; $out.Freed += $r.Bytes
|
|
373
|
+
if (-not $Script:WS.DryRun) { Write-Ok ("pruned {0} ({1}d idle, {2})" -f $u.Name, $u.Idle, (Format-Bytes $r.Bytes)) }
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
Write-Note ("kept {0} ({1}d idle, within the {2}-day window)" -f $u.Name, $u.Idle, $Days)
|
|
377
|
+
$out.Kept++
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return $out
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function Clear-DirectoryContents {
|
|
384
|
+
<# .SYNOPSIS Remove everything inside Path (never Path itself). -OlderThanDays limits it to idle entries. #>
|
|
385
|
+
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [int] $OlderThanDays = 0, [string] $Label = '')
|
|
386
|
+
$out = [pscustomobject]@{ Freed = [long]0; Items = 0; Skipped = 0; Exists = $false }
|
|
387
|
+
if (-not $Label) { $Label = $Path }
|
|
388
|
+
$p = Get-FullPath $Path
|
|
389
|
+
if (-not (Test-DirPresent $p)) { Write-Info "$Label - absent"; return $out }
|
|
390
|
+
if (Test-ReparsePoint (Get-ItemInfo $p)) { Write-Note "$Label - is a link, not entered"; return $out }
|
|
391
|
+
$out.Exists = $true
|
|
392
|
+
$why = Get-ProtectionReason $p
|
|
393
|
+
if ($why) { Write-Err "REFUSE ($why): $p"; return $out }
|
|
394
|
+
if (-not (Test-PathWithin -Path $p -Within $Within)) { Write-Err "REFUSE (outside declared root $Within): $p"; return $out }
|
|
395
|
+
$entries = @(Get-ChildEntries $p)
|
|
396
|
+
if ($entries.Count -eq 0) { Write-Info "$Label - already empty"; return $out }
|
|
397
|
+
if ($Script:WS.DryRun) {
|
|
398
|
+
# One line per folder in dry-run: the estimate is the sum of what each eligible child would free.
|
|
399
|
+
$est = [long]0; $n = 0
|
|
400
|
+
foreach ($e in $entries) {
|
|
401
|
+
$full = Remove-LongPrefix $e.FullName
|
|
402
|
+
if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
|
|
403
|
+
if (Get-ProtectionReason $full) { continue }
|
|
404
|
+
if (Test-ReparsePoint $e) { $n++; continue }
|
|
405
|
+
if ($e -is [IO.DirectoryInfo]) { $est += (Get-DirectoryStats $full).Bytes } else { $est += [long]$e.Length }
|
|
406
|
+
$n++
|
|
407
|
+
}
|
|
408
|
+
if ($n -eq 0) { Write-Info "$Label - nothing eligible"; return $out }
|
|
409
|
+
$age = ''
|
|
410
|
+
if ($OlderThanDays -gt 0) { $age = " idle $OlderThanDays+ days" }
|
|
411
|
+
Write-DryRun ("would clear {0} in {1} items{2} from {3}" -f (Format-Bytes $est), $n, $age, $p)
|
|
412
|
+
Add-Freed $est
|
|
413
|
+
$out.Items = $n; $out.Freed = $est
|
|
414
|
+
return $out
|
|
415
|
+
}
|
|
416
|
+
foreach ($e in $entries) {
|
|
417
|
+
$full = Remove-LongPrefix $e.FullName
|
|
418
|
+
if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
|
|
419
|
+
$r = Remove-PathSafe -Path $full -Within $p -Label $Label
|
|
420
|
+
if ($r.Removed) { $out.Items++; $out.Freed += $r.Bytes }
|
|
421
|
+
$out.Skipped += $r.Skipped
|
|
422
|
+
}
|
|
423
|
+
if (-not $Script:WS.DryRun -and $out.Items -gt 0) {
|
|
424
|
+
Write-Ok ("$Label - cleared {0} ({1} items, {2} skipped in use)" -f (Format-Bytes $out.Freed), $out.Items, $out.Skipped)
|
|
425
|
+
} elseif (-not $Script:WS.DryRun) {
|
|
426
|
+
Write-Info "$Label - nothing eligible"
|
|
427
|
+
}
|
|
428
|
+
return $out
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function Send-ToRecycleBin {
|
|
432
|
+
<# .SYNOPSIS Move a personal file/folder to the Recycle Bin (or delete when --permanent). Same guards as Remove-PathSafe. #>
|
|
433
|
+
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within)
|
|
434
|
+
$ws = $Script:WS
|
|
435
|
+
if ($ws.Permanent) { return (Remove-PathSafe -Path $Path -Within $Within) }
|
|
436
|
+
$r = New-RemoveResult
|
|
437
|
+
$p = Get-FullPath $Path
|
|
438
|
+
$why = Get-ProtectionReason $p
|
|
439
|
+
if ($why) { $r.Reason = $why; Add-ExcludedRefusal $why; Write-Err "REFUSE ($why): $p"; return $r }
|
|
440
|
+
if (-not (Test-PathWithin -Path $p -Within $Within) -or $p.Equals((Get-FullPath $Within), [StringComparison]::OrdinalIgnoreCase)) {
|
|
441
|
+
$r.Reason = "outside declared root $Within"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r
|
|
442
|
+
}
|
|
443
|
+
$info = Get-ItemInfo $p
|
|
444
|
+
if ($null -eq $info) { $r.Removed = $true; return $r }
|
|
445
|
+
$bytes = [long](Get-DirectoryStats $p).Bytes
|
|
446
|
+
if ($ws.DryRun) { Write-DryRun ("would move to Recycle Bin {0,10} {1}" -f (Format-Bytes $bytes), $p); $r.Removed = $true; $r.Bytes = $bytes; Add-Freed $bytes; return $r }
|
|
447
|
+
try {
|
|
448
|
+
Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop
|
|
449
|
+
if ($info -is [IO.DirectoryInfo]) {
|
|
450
|
+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
|
|
451
|
+
} else {
|
|
452
|
+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
|
|
453
|
+
}
|
|
454
|
+
$r.Removed = $true; $r.Bytes = $bytes
|
|
455
|
+
Add-Freed $bytes
|
|
456
|
+
Write-LogLine "recycled: $p ($bytes bytes)"
|
|
457
|
+
Write-Ok ("moved to Recycle Bin: {0} ({1})" -f $p, (Format-Bytes $bytes))
|
|
458
|
+
} catch {
|
|
459
|
+
$r.Skipped++; $r.Reason = $_.Exception.Message
|
|
460
|
+
Write-Warn "could not recycle $p - $($_.Exception.Message)"
|
|
461
|
+
}
|
|
462
|
+
return $r
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function Invoke-External {
|
|
466
|
+
<# .SYNOPSIS Run an external command, streaming its output to the log. -Destructive commands are skipped in --dry-run. #>
|
|
467
|
+
param([Parameter(Mandatory = $true)][string] $FilePath, [string[]] $ArgumentList = @(), [switch] $Destructive, [switch] $Quiet, [string] $Label = '')
|
|
468
|
+
$display = "$FilePath $($ArgumentList -join ' ')"
|
|
469
|
+
$tag = ''
|
|
470
|
+
if ($Label) { $tag = " [$Label]" }
|
|
471
|
+
if ($Destructive -and $Script:WS.DryRun) {
|
|
472
|
+
Write-DryRun "would run: $display"
|
|
473
|
+
return [pscustomobject]@{ Ran = $false; ExitCode = 0; Output = @() }
|
|
474
|
+
}
|
|
475
|
+
Write-LogLine "run${tag}: $display"
|
|
476
|
+
$lines = @()
|
|
477
|
+
try {
|
|
478
|
+
$raw = & $FilePath @ArgumentList 2>&1
|
|
479
|
+
foreach ($l in $raw) {
|
|
480
|
+
$s = [string]$l
|
|
481
|
+
$lines += $s
|
|
482
|
+
if (-not $Quiet) { Write-Note $s } else { Write-LogLine $s }
|
|
483
|
+
}
|
|
484
|
+
$code = $LASTEXITCODE
|
|
485
|
+
if ($null -eq $code) { $code = 0 }
|
|
486
|
+
} catch {
|
|
487
|
+
$lines += $_.Exception.Message
|
|
488
|
+
$code = 1
|
|
489
|
+
}
|
|
490
|
+
Write-LogLine "exit $code$tag : $display"
|
|
491
|
+
return [pscustomobject]@{ Ran = $true; ExitCode = $code; Output = $lines }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function Test-CommandPresent {
|
|
495
|
+
param([string] $Name)
|
|
496
|
+
return ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function Test-ProcessRunning {
|
|
500
|
+
<# .SYNOPSIS True when any process with one of the given names is running. #>
|
|
501
|
+
param([string[]] $Names)
|
|
502
|
+
foreach ($n in $Names) {
|
|
503
|
+
if (@(Get-Process -Name $n -ErrorAction SilentlyContinue).Count -gt 0) { return $true }
|
|
504
|
+
}
|
|
505
|
+
return $false
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function Test-IsAdmin {
|
|
509
|
+
try {
|
|
510
|
+
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
511
|
+
return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
512
|
+
} catch { return $false }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function Test-CanElevate {
|
|
516
|
+
<# .SYNOPSIS True when the account belongs to Administrators (even in a filtered, non-elevated token). #>
|
|
517
|
+
try {
|
|
518
|
+
$out = & "$env:SystemRoot\System32\whoami.exe" /groups /fo csv 2>$null
|
|
519
|
+
foreach ($l in $out) { if ($l -match 'S-1-5-32-544') { return $true } }
|
|
520
|
+
} catch { $null = $_ }
|
|
521
|
+
return $false
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function Invoke-Elevated {
|
|
525
|
+
<# .SYNOPSIS Relaunch this run elevated (UAC prompt) and wait for it. Returns the child's exit code. #>
|
|
526
|
+
$ws = $Script:WS
|
|
527
|
+
$childArgs = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $ws.ScriptPath + '"'))
|
|
528
|
+
foreach ($a in $ws.RawArgs) {
|
|
529
|
+
if ($a -eq '--elevate') { continue }
|
|
530
|
+
if ($a -match '[\s"]') { $childArgs += ('"' + ($a -replace '"', '\"') + '"') } else { $childArgs += $a }
|
|
531
|
+
}
|
|
532
|
+
$childArgs += '--elevated-child'
|
|
533
|
+
Write-Info 'Requesting elevation (a UAC prompt appears). The elevated run opens in a new window and writes its own log.'
|
|
534
|
+
try {
|
|
535
|
+
$proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $childArgs -Verb RunAs -Wait -PassThru
|
|
536
|
+
return $proc.ExitCode
|
|
537
|
+
} catch {
|
|
538
|
+
Write-Err "elevation refused or failed: $($_.Exception.Message)"
|
|
539
|
+
return $Script:WS_EXIT_REFUSED
|
|
540
|
+
}
|
|
541
|
+
}
|