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/lib/log.ps1 ADDED
@@ -0,0 +1,140 @@
1
+ # log.ps1 - session log, freed-bytes tally, drive snapshots and the JSON session report.
2
+
3
+ function Initialize-Log {
4
+ <# .SYNOPSIS Create the data directories and open this run's log with a credit header. #>
5
+ $ws = $Script:WS
6
+ foreach ($d in @($ws.LogsDir, $ws.ReportsDir)) {
7
+ if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
8
+ }
9
+ $ws.LogFile = Join-Path $ws.LogsDir ("$Script:WS_NAME-" + $ws.Stamp + '.log')
10
+ $elev = 'no'
11
+ if ($ws.IsAdmin) { $elev = 'yes' }
12
+ $dry = 'no'
13
+ if ($ws.DryRun) { $dry = 'yes' }
14
+ $header = @(
15
+ '# ============================================================'
16
+ "# $Script:WS_NAME v$(Get-ToolVersion) - session log"
17
+ "# Author: $Script:WS_AUTHOR <$Script:WS_EMAIL>"
18
+ "# Web: $Script:WS_WEB"
19
+ "# License: $Script:WS_LICENSE"
20
+ "# Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
21
+ "# Mode: $($ws.Mode)"
22
+ "# Host: $env:COMPUTERNAME"
23
+ "# User: $env:USERNAME"
24
+ "# Elevated: $elev"
25
+ "# Dry-run: $dry"
26
+ "# Launcher: $($ws.Launcher)"
27
+ '# ============================================================'
28
+ )
29
+ try { [IO.File]::WriteAllLines($ws.LogFile, $header) } catch { $ws.LogFile = $null }
30
+ }
31
+
32
+ function Write-Log {
33
+ <# .SYNOPSIS Append one timestamped line to the run log; never throws. #>
34
+ param([string] $Text)
35
+ $f = $Script:WS.LogFile
36
+ if (-not $f) { return }
37
+ try { [IO.File]::AppendAllText($f, ("[{0}] {1}`r`n" -f (Get-Date -Format 'HH:mm:ss'), $Text)) } catch { $null = $_ }
38
+ }
39
+
40
+ function Add-Freed {
41
+ <# .SYNOPSIS Tally bytes freed (or, in dry-run, bytes that would be freed). #>
42
+ param([long] $Bytes)
43
+ if ($Bytes -le 0) { return }
44
+ if ($Script:WS.DryRun) { $Script:WS.TotalEstimated += $Bytes } else { $Script:WS.TotalFreed += $Bytes }
45
+ $Script:WS.SectionFreed += $Bytes
46
+ }
47
+
48
+ function Get-DriveSnapshot {
49
+ <# .SYNOPSIS Size/free bytes of every ready fixed drive. #>
50
+ $rows = @()
51
+ foreach ($d in [IO.DriveInfo]::GetDrives()) {
52
+ try {
53
+ if (-not $d.IsReady) { continue }
54
+ if ($d.DriveType -ne [IO.DriveType]::Fixed) { continue }
55
+ $rows += [ordered]@{ drive = $d.Name.TrimEnd('\'); size_bytes = [long]$d.TotalSize; free_bytes = [long]$d.AvailableFreeSpace }
56
+ } catch { $null = $_ }
57
+ }
58
+ return $rows
59
+ }
60
+
61
+ function Get-SystemDriveFree {
62
+ <# .SYNOPSIS Free bytes on the system drive (used for before/after deltas). #>
63
+ try { return [long](New-Object IO.DriveInfo($env:SystemDrive)).AvailableFreeSpace } catch { return [long]0 }
64
+ }
65
+
66
+ function Show-DriveTable {
67
+ <# .SYNOPSIS Print size / used / free / percent for every fixed drive. #>
68
+ Write-UiLine (" {0,-6} {1,10} {2,10} {3,10} {4,6}" -f 'DRIVE', 'SIZE', 'USED', 'FREE', 'USE%') 'White'
69
+ foreach ($d in (Get-DriveSnapshot)) {
70
+ $used = $d.size_bytes - $d.free_bytes
71
+ $pct = 0
72
+ if ($d.size_bytes -gt 0) { $pct = [math]::Round(100.0 * $used / $d.size_bytes) }
73
+ $color = 'Gray'
74
+ if ($pct -ge 90) { $color = 'Red' } elseif ($pct -ge 80) { $color = 'Yellow' }
75
+ Write-UiLine (" {0,-6} {1,10} {2,10} {3,10} {4,5}%" -f $d.drive, (Format-Bytes $d.size_bytes), (Format-Bytes $used), (Format-Bytes $d.free_bytes), $pct) $color
76
+ }
77
+ }
78
+
79
+ function Initialize-Report {
80
+ $ws = $Script:WS
81
+ $ws.Report = @{
82
+ startedAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
83
+ startedTicks = (Get-Date).Ticks
84
+ drivesBefore = @(Get-DriveSnapshot)
85
+ steps = New-Object System.Collections.ArrayList
86
+ }
87
+ }
88
+
89
+ function Add-ReportStep {
90
+ <# .SYNOPSIS Record a section outcome for the JSON report. Status: ran | skipped | refused | failed | dry-run #>
91
+ param([int] $Section, [string] $Title, [string] $Status, [long] $Freed = 0, [string] $Note = '')
92
+ $ws = $Script:WS
93
+ if (-not $ws.Report) { return }
94
+ $null = $ws.Report.steps.Add([ordered]@{ n = ($ws.Report.steps.Count + 1); section = $Section; title = $Title; status = $Status; freed_bytes = $Freed; note = $Note })
95
+ }
96
+
97
+ function Save-Report {
98
+ <# .SYNOPSIS Write the canonical JSON report (schema 1). Returns the path or $null. #>
99
+ $ws = $Script:WS
100
+ if ($ws.NoReport -or -not $ws.Report) { return $null }
101
+ $ran = 0; $skipped = 0
102
+ foreach ($s in $ws.Report.steps) { if ($s.status -eq 'ran' -or $s.status -eq 'dry-run') { $ran++ } else { $skipped++ } }
103
+ $dev = $null
104
+ if ($null -ne $ws.Developer) { $dev = [bool]$ws.Developer }
105
+ $doc = [ordered]@{
106
+ schema_version = 1
107
+ credits = [ordered]@{
108
+ tool = $Script:WS_NAME; tool_version = (Get-ToolVersion); tool_homepage = $Script:WS_REPO; tool_license = $Script:WS_LICENSE
109
+ author = [ordered]@{ name = $Script:WS_AUTHOR; email = $Script:WS_EMAIL; website = $Script:WS_WEB; linkedin = $Script:WS_LINKEDIN }
110
+ }
111
+ meta = [ordered]@{
112
+ started_at = $ws.Report.startedAt
113
+ finished_at = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
114
+ duration_seconds = [int][math]::Round(((Get-Date).Ticks - $ws.Report.startedTicks) / 10000000)
115
+ host = $env:COMPUTERNAME; user = $env:USERNAME
116
+ os = $ws.OsCaption; powershell = $PSVersionTable.PSVersion.ToString()
117
+ mode = $ws.Mode; dry_run = [bool]$ws.DryRun; elevated = [bool]$ws.IsAdmin; developer_mode = $dev
118
+ idle_days = $ws.Days; temp_days = $ws.TempDays
119
+ log_file = $ws.LogFile; launcher = $ws.Launcher; via_npx = [bool]$ws.Npx
120
+ }
121
+ disk = [ordered]@{ before = @($ws.Report.drivesBefore); after = @(Get-DriveSnapshot) }
122
+ steps = @($ws.Report.steps)
123
+ totals = [ordered]@{
124
+ total_reclaimed_bytes = [long]$ws.TotalFreed
125
+ total_reclaimed_human = (Format-Bytes $ws.TotalFreed)
126
+ total_estimated_bytes = [long]$ws.TotalEstimated
127
+ total_estimated_human = (Format-Bytes $ws.TotalEstimated)
128
+ steps_run = $ran; steps_skipped = $skipped
129
+ }
130
+ }
131
+ $path = Join-Path $ws.ReportsDir ('report-' + $ws.Stamp + '.json')
132
+ try {
133
+ [IO.File]::WriteAllText($path, ($doc | ConvertTo-Json -Depth 8), (New-Object System.Text.UTF8Encoding($false)))
134
+ $ws.ReportFile = $path
135
+ return $path
136
+ } catch {
137
+ Write-Warn "could not write report: $($_.Exception.Message)"
138
+ return $null
139
+ }
140
+ }
package/lib/safety.ps1 ADDED
@@ -0,0 +1,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 Get-ProtectionReason {
96
+ <# .SYNOPSIS Why a path may not be deleted, or $null when it is allowed.
97
+ Hot path: called once per file during a prune, so it uses only the tables Initialize-Safety pre-normalized.
98
+ "$p\" starts with "$root\" is exactly Test-PathWithin (equal, or inside) without the per-call resolution. #>
99
+ param([string] $Path)
100
+ if ([string]::IsNullOrWhiteSpace($Path)) { return 'empty path' }
101
+ $raw = $Path.Trim()
102
+ if ($raw -match '(^|[\\/])\.\.([\\/]|$)') { return 'relative ".." segment' }
103
+ if ($raw.StartsWith('\\') -and -not $raw.StartsWith('\\?\')) { return 'UNC path' }
104
+ $p = Get-FullPath $raw
105
+ if (-not $p) { return 'unresolvable path' }
106
+ if ($p.StartsWith('\\')) { return 'UNC path' }
107
+ if ($p -match '^[A-Za-z]:\\?$') { return 'drive root' }
108
+ $pr = $Script:WS_PROTECT
109
+ if ($pr.Exact.Contains($p)) { return "protected root: $p" }
110
+ $ic = [StringComparison]::OrdinalIgnoreCase
111
+ $pSlash = $p + '\'
112
+ foreach ($e in $pr.ExceptionPrefixes) { if ($pSlash.StartsWith($e, $ic)) { return $null } }
113
+ for ($i = 0; $i -lt $pr.SubtreePrefixes.Count; $i++) {
114
+ if ($pSlash.StartsWith($pr.SubtreePrefixes[$i], $ic)) { return "inside protected: $($pr.Subtrees[$i])" }
115
+ }
116
+ for ($i = 0; $i -lt $pr.PatternObjects.Count; $i++) {
117
+ if ($pr.PatternObjects[$i].IsMatch($p)) { return "matches protected pattern: $($pr.Patterns[$i])" }
118
+ }
119
+ $base = [IO.Path]::GetFileName($p)
120
+ foreach ($b in $pr.Basenames) { if ($base.Equals($b, $ic)) { return "protected file name: $b" } }
121
+ if ($Script:WS.Home -and -not $Script:WS.AllowOwnData) {
122
+ if (-not $pr.HomePrefix) { $pr.HomePrefix = (Get-FullPath $Script:WS.Home) + '\' }
123
+ if ($pSlash.StartsWith($pr.HomePrefix, $ic)) { return 'the tool''s own data directory' }
124
+ }
125
+ return $null
126
+ }
127
+
128
+ function Test-ProtectedPath {
129
+ param([string] $Path)
130
+ return ($null -ne (Get-ProtectionReason $Path))
131
+ }
132
+
133
+ function New-RemoveResult {
134
+ return [pscustomobject]@{ Removed = $false; Bytes = [long]0; Files = 0; Skipped = 0; SkippedBytes = [long]0; Reason = $null }
135
+ }
136
+
137
+ function Remove-ReadOnlyAttribute {
138
+ param([string] $LongPath)
139
+ try {
140
+ $a = [IO.File]::GetAttributes($LongPath)
141
+ if (($a -band [IO.FileAttributes]::ReadOnly) -ne 0) { [IO.File]::SetAttributes($LongPath, ($a -bxor [IO.FileAttributes]::ReadOnly)) }
142
+ } catch { $null = $_ }
143
+ }
144
+
145
+ function Remove-TreeInternal {
146
+ <# .SYNOPSIS Recursive delete that removes nested reparse points as links and skips locked files. Internal: guards already ran. #>
147
+ param([string] $Path, [pscustomobject] $Result)
148
+ $lp = Get-LongPath $Path
149
+ $entries = @()
150
+ try { $entries = (New-Object IO.DirectoryInfo($lp)).EnumerateFileSystemInfos() } catch { $Result.Skipped++; return $false }
151
+ $allClear = $true
152
+ foreach ($e in $entries) {
153
+ $isLink = (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
154
+ $isDir = (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0)
155
+ try {
156
+ if ($isLink) {
157
+ if ($isDir) { [IO.Directory]::Delete($e.FullName, $false) } else { [IO.File]::Delete($e.FullName) }
158
+ Write-Log ("removed link (not followed): " + (Remove-LongPrefix $e.FullName))
159
+ } elseif ($isDir) {
160
+ if (-not (Remove-TreeInternal -Path $e.FullName -Result $Result)) { $allClear = $false }
161
+ } else {
162
+ Remove-ReadOnlyAttribute $e.FullName
163
+ [IO.File]::Delete($e.FullName)
164
+ $Result.Files++
165
+ }
166
+ } catch {
167
+ $allClear = $false
168
+ $Result.Skipped++
169
+ try { if (-not $isDir) { $Result.SkippedBytes += [long]$e.Length } } catch { $null = $_ }
170
+ }
171
+ }
172
+ if ($allClear) {
173
+ try { [IO.Directory]::Delete($lp, $false) } catch { $allClear = $false; $Result.Skipped++ }
174
+ }
175
+ return $allClear
176
+ }
177
+
178
+ function Remove-PathSafe {
179
+ <# .SYNOPSIS The chokepoint. Removes one file, directory or link after every guard passes. Honours --dry-run. #>
180
+ param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [string] $Label = '')
181
+ $ws = $Script:WS
182
+ $r = New-RemoveResult
183
+ $tag = ''
184
+ if ($Label) { $tag = " [$Label]" }
185
+ $p = Get-FullPath $Path
186
+ $w = Get-FullPath $Within
187
+ if (-not $p -or -not $w) { $r.Reason = 'unresolvable path'; Write-Err "REFUSE ($($r.Reason)): $Path"; return $r }
188
+ $why = Get-ProtectionReason $p
189
+ if ($why) { $r.Reason = $why; Write-Err "REFUSE ($why): $p"; return $r }
190
+ if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { $r.Reason = 'target is its own declared root'; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
191
+ if (-not (Test-PathWithin -Path $p -Within $w)) { $r.Reason = "outside declared root $w"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
192
+ $info = Get-ItemInfo $p
193
+ if ($null -eq $info) { $r.Removed = $true; return $r }
194
+ $isDir = ($info -is [IO.DirectoryInfo])
195
+ $isLink = Test-ReparsePoint $info
196
+ $bytes = [long]0
197
+ if ($isLink) { $bytes = 0 } elseif ($isDir) { $bytes = (Get-DirectoryStats $p).Bytes } else { $bytes = [long]$info.Length }
198
+ if ($ws.DryRun) {
199
+ Write-DryRun ("would remove {0,10} {1}" -f (Format-Bytes $bytes), $p)
200
+ $r.Removed = $true; $r.Bytes = $bytes
201
+ Add-Freed $bytes
202
+ return $r
203
+ }
204
+ $lp = Get-LongPath $p
205
+ try {
206
+ if ($isLink) {
207
+ if ($isDir) { [IO.Directory]::Delete($lp, $false) } else { [IO.File]::Delete($lp) }
208
+ $r.Removed = $true
209
+ Write-Log "removed link (not followed): $p$tag"
210
+ } elseif ($isDir) {
211
+ $r.Removed = Remove-TreeInternal -Path $p -Result $r
212
+ $r.Bytes = [math]::Max([long]0, $bytes - $r.SkippedBytes)
213
+ if ($r.Skipped -gt 0) { Write-Note "$($r.Skipped) item(s) in use or inaccessible, left in place under $p" }
214
+ Write-Log ("removed dir: $p ($($r.Bytes) bytes, skipped $($r.Skipped))$tag")
215
+ } else {
216
+ Remove-ReadOnlyAttribute $lp
217
+ [IO.File]::Delete($lp)
218
+ $r.Removed = $true; $r.Bytes = $bytes; $r.Files = 1
219
+ Write-Log "removed file: $p ($bytes bytes)$tag"
220
+ }
221
+ Add-Freed $r.Bytes
222
+ } catch {
223
+ $r.Skipped++
224
+ $r.Reason = 'in use or access denied'
225
+ Write-Note "in use or inaccessible, skipped: $p"
226
+ Write-Log "skip (locked): $p - $($_.Exception.Message)"
227
+ }
228
+ return $r
229
+ }
230
+
231
+ function Remove-StaleFiles {
232
+ <# .SYNOPSIS Prune files idle >= Days under Root (both timestamps rule), then sweep empty directories. #>
233
+ param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days, [string] $Label = '')
234
+ $ws = $Script:WS
235
+ $out = [pscustomobject]@{ Freed = [long]0; Files = 0; Skipped = 0; Exists = $false }
236
+ if (-not $Label) { $Label = $Root }
237
+ $root = Get-FullPath $Root
238
+ if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
239
+ if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
240
+ $out.Exists = $true
241
+ $why = Get-ProtectionReason $root
242
+ if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
243
+ if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
244
+ $scan = Get-StaleFiles -Root $root -Days $Days
245
+ $total = [long]0
246
+ foreach ($f in $scan.Files) { $total += $f.Bytes }
247
+ if ($scan.Files.Count -eq 0) { Write-Info "$Label - nothing idle for $Days+ days"; return $out }
248
+ if ($ws.DryRun) {
249
+ Write-DryRun ("would prune {0} in {1} files idle {2}+ days from {3}" -f (Format-Bytes $total), $scan.Files.Count, $Days, $root)
250
+ $out.Freed = $total; $out.Files = $scan.Files.Count
251
+ Add-Freed $total
252
+ return $out
253
+ }
254
+ foreach ($f in $scan.Files) {
255
+ $why = Get-ProtectionReason $f.Path
256
+ if ($why) { $out.Skipped++; continue }
257
+ try {
258
+ Remove-ReadOnlyAttribute $f.Path
259
+ [IO.File]::Delete($f.Path)
260
+ $out.Freed += $f.Bytes; $out.Files++
261
+ } catch { $out.Skipped++ }
262
+ }
263
+ # Sweep directories that became empty, deepest first; never the root itself.
264
+ $dirs = @($scan.Dirs | Sort-Object { $_.Length } -Descending)
265
+ foreach ($d in $dirs) {
266
+ try {
267
+ $di = New-Object IO.DirectoryInfo($d)
268
+ if (($di.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
269
+ $any = $false
270
+ foreach ($x in $di.EnumerateFileSystemInfos()) { $any = $true; break }
271
+ if (-not $any) { [IO.Directory]::Delete($d, $false) }
272
+ } catch { $null = $_ }
273
+ }
274
+ Add-Freed $out.Freed
275
+ Write-Log ("pruned $($out.Files) files ($($out.Freed) bytes) idle $Days+ days from $root; skipped $($out.Skipped)")
276
+ 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)
277
+ return $out
278
+ }
279
+
280
+ function Remove-StaleUnits {
281
+ <# .SYNOPSIS Treat each top-level child as one unit (a tool version, an AVD). Delete units idle >= Days;
282
+ -KeepNewest never removes the freshest unit of each group (-GroupBy maps a name to its group; default: one group). #>
283
+ param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days,
284
+ [string] $Pattern = '*', [switch] $KeepNewest, [switch] $DirectoriesOnly, [scriptblock] $GroupBy = $null,
285
+ [string[]] $ExcludeNames = @(), [string] $Label = '')
286
+ $out = [pscustomobject]@{ Inspected = 0; Pruned = 0; Kept = 0; Freed = [long]0; Exists = $false }
287
+ if (-not $Label) { $Label = $Root }
288
+ $root = Get-FullPath $Root
289
+ if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
290
+ if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
291
+ $out.Exists = $true
292
+ $why = Get-ProtectionReason $root
293
+ if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
294
+ if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
295
+ $units = @()
296
+ foreach ($e in (Get-ChildEntries $root)) {
297
+ if ($DirectoriesOnly -and -not ($e -is [IO.DirectoryInfo])) { continue }
298
+ if ($e.Name -notlike $Pattern) { continue }
299
+ if ($ExcludeNames -contains $e.Name) { continue }
300
+ if (Test-ReparsePoint $e) { continue }
301
+ $full = Remove-LongPrefix $e.FullName
302
+ $group = 'all'
303
+ if ($GroupBy) { $group = [string](& $GroupBy $e.Name) }
304
+ $units += [pscustomobject]@{ Name = $e.Name; Path = $full; Idle = (Get-IdleDays $full); IsDir = ($e -is [IO.DirectoryInfo]); Group = $group }
305
+ }
306
+ if ($units.Count -eq 0) { Write-Info "$Label - nothing to inspect"; return $out }
307
+ $keep = @{}
308
+ if ($KeepNewest) {
309
+ foreach ($g in ($units | Group-Object Group)) {
310
+ $freshest = $g.Group | Sort-Object Idle | Select-Object -First 1
311
+ $keep[$freshest.Path] = $true
312
+ }
313
+ }
314
+ foreach ($u in ($units | Sort-Object Name)) {
315
+ $out.Inspected++
316
+ if ($KeepNewest -and $keep.ContainsKey($u.Path)) {
317
+ Write-Note ("kept {0} (newest of its kind, {1}d idle)" -f $u.Name, $u.Idle)
318
+ $out.Kept++
319
+ continue
320
+ }
321
+ if ($u.Idle -ge $Days) {
322
+ $r = Remove-PathSafe -Path $u.Path -Within $Within -Label $u.Name
323
+ if ($r.Removed) {
324
+ $out.Pruned++; $out.Freed += $r.Bytes
325
+ if (-not $Script:WS.DryRun) { Write-Ok ("pruned {0} ({1}d idle, {2})" -f $u.Name, $u.Idle, (Format-Bytes $r.Bytes)) }
326
+ }
327
+ } else {
328
+ Write-Note ("kept {0} ({1}d idle, within the {2}-day window)" -f $u.Name, $u.Idle, $Days)
329
+ $out.Kept++
330
+ }
331
+ }
332
+ return $out
333
+ }
334
+
335
+ function Clear-DirectoryContents {
336
+ <# .SYNOPSIS Remove everything inside Path (never Path itself). -OlderThanDays limits it to idle entries. #>
337
+ param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [int] $OlderThanDays = 0, [string] $Label = '')
338
+ $out = [pscustomobject]@{ Freed = [long]0; Items = 0; Skipped = 0; Exists = $false }
339
+ if (-not $Label) { $Label = $Path }
340
+ $p = Get-FullPath $Path
341
+ if (-not (Test-DirPresent $p)) { Write-Info "$Label - absent"; return $out }
342
+ if (Test-ReparsePoint (Get-ItemInfo $p)) { Write-Note "$Label - is a link, not entered"; return $out }
343
+ $out.Exists = $true
344
+ $why = Get-ProtectionReason $p
345
+ if ($why) { Write-Err "REFUSE ($why): $p"; return $out }
346
+ if (-not (Test-PathWithin -Path $p -Within $Within)) { Write-Err "REFUSE (outside declared root $Within): $p"; return $out }
347
+ $entries = @(Get-ChildEntries $p)
348
+ if ($entries.Count -eq 0) { Write-Info "$Label - already empty"; return $out }
349
+ if ($Script:WS.DryRun) {
350
+ # One line per folder in dry-run: the estimate is the sum of what each eligible child would free.
351
+ $est = [long]0; $n = 0
352
+ foreach ($e in $entries) {
353
+ $full = Remove-LongPrefix $e.FullName
354
+ if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
355
+ if (Get-ProtectionReason $full) { continue }
356
+ if (Test-ReparsePoint $e) { $n++; continue }
357
+ if ($e -is [IO.DirectoryInfo]) { $est += (Get-DirectoryStats $full).Bytes } else { $est += [long]$e.Length }
358
+ $n++
359
+ }
360
+ if ($n -eq 0) { Write-Info "$Label - nothing eligible"; return $out }
361
+ $age = ''
362
+ if ($OlderThanDays -gt 0) { $age = " idle $OlderThanDays+ days" }
363
+ Write-DryRun ("would clear {0} in {1} items{2} from {3}" -f (Format-Bytes $est), $n, $age, $p)
364
+ Add-Freed $est
365
+ $out.Items = $n; $out.Freed = $est
366
+ return $out
367
+ }
368
+ foreach ($e in $entries) {
369
+ $full = Remove-LongPrefix $e.FullName
370
+ if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
371
+ $r = Remove-PathSafe -Path $full -Within $p -Label $Label
372
+ if ($r.Removed) { $out.Items++; $out.Freed += $r.Bytes }
373
+ $out.Skipped += $r.Skipped
374
+ }
375
+ if (-not $Script:WS.DryRun -and $out.Items -gt 0) {
376
+ Write-Ok ("$Label - cleared {0} ({1} items, {2} skipped in use)" -f (Format-Bytes $out.Freed), $out.Items, $out.Skipped)
377
+ } elseif (-not $Script:WS.DryRun) {
378
+ Write-Info "$Label - nothing eligible"
379
+ }
380
+ return $out
381
+ }
382
+
383
+ function Send-ToRecycleBin {
384
+ <# .SYNOPSIS Move a personal file/folder to the Recycle Bin (or delete when --permanent). Same guards as Remove-PathSafe. #>
385
+ param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within)
386
+ $ws = $Script:WS
387
+ if ($ws.Permanent) { return (Remove-PathSafe -Path $Path -Within $Within) }
388
+ $r = New-RemoveResult
389
+ $p = Get-FullPath $Path
390
+ $why = Get-ProtectionReason $p
391
+ if ($why) { $r.Reason = $why; Write-Err "REFUSE ($why): $p"; return $r }
392
+ if (-not (Test-PathWithin -Path $p -Within $Within) -or $p.Equals((Get-FullPath $Within), [StringComparison]::OrdinalIgnoreCase)) {
393
+ $r.Reason = "outside declared root $Within"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r
394
+ }
395
+ $info = Get-ItemInfo $p
396
+ if ($null -eq $info) { $r.Removed = $true; return $r }
397
+ $bytes = [long](Get-DirectoryStats $p).Bytes
398
+ 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 }
399
+ try {
400
+ Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop
401
+ if ($info -is [IO.DirectoryInfo]) {
402
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
403
+ } else {
404
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
405
+ }
406
+ $r.Removed = $true; $r.Bytes = $bytes
407
+ Add-Freed $bytes
408
+ Write-Log "recycled: $p ($bytes bytes)"
409
+ Write-Ok ("moved to Recycle Bin: {0} ({1})" -f $p, (Format-Bytes $bytes))
410
+ } catch {
411
+ $r.Skipped++; $r.Reason = $_.Exception.Message
412
+ Write-Warn "could not recycle $p - $($_.Exception.Message)"
413
+ }
414
+ return $r
415
+ }
416
+
417
+ function Invoke-External {
418
+ <# .SYNOPSIS Run an external command, streaming its output to the log. -Destructive commands are skipped in --dry-run. #>
419
+ param([Parameter(Mandatory = $true)][string] $FilePath, [string[]] $ArgumentList = @(), [switch] $Destructive, [switch] $Quiet, [string] $Label = '')
420
+ $display = "$FilePath $($ArgumentList -join ' ')"
421
+ $tag = ''
422
+ if ($Label) { $tag = " [$Label]" }
423
+ if ($Destructive -and $Script:WS.DryRun) {
424
+ Write-DryRun "would run: $display"
425
+ return [pscustomobject]@{ Ran = $false; ExitCode = 0; Output = @() }
426
+ }
427
+ Write-Log "run${tag}: $display"
428
+ $lines = @()
429
+ try {
430
+ $raw = & $FilePath @ArgumentList 2>&1
431
+ foreach ($l in $raw) {
432
+ $s = [string]$l
433
+ $lines += $s
434
+ if (-not $Quiet) { Write-Note $s } else { Write-Log $s }
435
+ }
436
+ $code = $LASTEXITCODE
437
+ if ($null -eq $code) { $code = 0 }
438
+ } catch {
439
+ $lines += $_.Exception.Message
440
+ $code = 1
441
+ }
442
+ Write-Log "exit $code$tag : $display"
443
+ return [pscustomobject]@{ Ran = $true; ExitCode = $code; Output = $lines }
444
+ }
445
+
446
+ function Test-CommandPresent {
447
+ param([string] $Name)
448
+ return ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue))
449
+ }
450
+
451
+ function Test-ProcessRunning {
452
+ <# .SYNOPSIS True when any process with one of the given names is running. #>
453
+ param([string[]] $Names)
454
+ foreach ($n in $Names) {
455
+ if (@(Get-Process -Name $n -ErrorAction SilentlyContinue).Count -gt 0) { return $true }
456
+ }
457
+ return $false
458
+ }
459
+
460
+ function Test-IsAdmin {
461
+ try {
462
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
463
+ return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
464
+ } catch { return $false }
465
+ }
466
+
467
+ function Test-CanElevate {
468
+ <# .SYNOPSIS True when the account belongs to Administrators (even in a filtered, non-elevated token). #>
469
+ try {
470
+ $out = & "$env:SystemRoot\System32\whoami.exe" /groups /fo csv 2>$null
471
+ foreach ($l in $out) { if ($l -match 'S-1-5-32-544') { return $true } }
472
+ } catch { $null = $_ }
473
+ return $false
474
+ }
475
+
476
+ function Invoke-Elevated {
477
+ <# .SYNOPSIS Relaunch this run elevated (UAC prompt) and wait for it. Returns the child's exit code. #>
478
+ $ws = $Script:WS
479
+ $childArgs = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $ws.ScriptPath + '"'))
480
+ foreach ($a in $ws.RawArgs) {
481
+ if ($a -eq '--elevate') { continue }
482
+ if ($a -match '[\s"]') { $childArgs += ('"' + ($a -replace '"', '\"') + '"') } else { $childArgs += $a }
483
+ }
484
+ $childArgs += '--elevated-child'
485
+ Write-Info 'Requesting elevation (a UAC prompt appears). The elevated run opens in a new window and writes its own log.'
486
+ try {
487
+ $proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $childArgs -Verb RunAs -Wait -PassThru
488
+ return $proc.ExitCode
489
+ } catch {
490
+ Write-Err "elevation refused or failed: $($_.Exception.Message)"
491
+ return $Script:WS_EXIT_REFUSED
492
+ }
493
+ }