windowsweep 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AI-INTEGRATION-GUIDE.md +16 -12
  2. package/CHANGELOG.md +82 -0
  3. package/LICENSE +21 -21
  4. package/README.md +104 -69
  5. package/VERSION +1 -1
  6. package/lib/actions.ps1 +212 -212
  7. package/lib/config.ps1 +108 -99
  8. package/lib/constants.ps1 +103 -93
  9. package/lib/external.ps1 +87 -0
  10. package/lib/fs.ps1 +189 -180
  11. package/lib/log.ps1 +140 -140
  12. package/lib/safety.ps1 +467 -493
  13. package/lib/scan.ps1 +109 -100
  14. package/lib/ui.ps1 +275 -275
  15. package/modules/android_avd.ps1 +36 -36
  16. package/modules/app_caches.ps1 +58 -58
  17. package/modules/browsers.ps1 +33 -33
  18. package/modules/build_tools.ps1 +35 -35
  19. package/modules/crash_trap.ps1 +89 -89
  20. package/modules/disk_usage.ps1 +49 -49
  21. package/modules/docker.ps1 +93 -93
  22. package/modules/editors.ps1 +150 -150
  23. package/modules/globals_audit.ps1 +236 -236
  24. package/modules/health.ps1 +101 -101
  25. package/modules/installed_programs.ps1 +118 -118
  26. package/modules/menu.ps1 +63 -63
  27. package/modules/notify.ps1 +60 -60
  28. package/modules/orphaned_appdata.ps1 +189 -189
  29. package/modules/personal.ps1 +126 -126
  30. package/modules/pkg_managers.ps1 +73 -73
  31. package/modules/projects.ps1 +160 -160
  32. package/modules/release_helpers.ps1 +438 -438
  33. package/modules/reports.ps1 +212 -212
  34. package/modules/runner.ps1 +246 -239
  35. package/modules/self_test_contract.ps1 +195 -0
  36. package/modules/self_test_extra.ps1 +366 -360
  37. package/modules/startup_audit.ps1 +126 -126
  38. package/modules/system_admin.ps1 +220 -220
  39. package/modules/test_runners.ps1 +24 -24
  40. package/modules/walkthrough.ps1 +80 -79
  41. package/modules/windows_user.ps1 +118 -118
  42. package/package.json +3 -3
  43. package/windowsweep.ps1 +322 -316
package/windowsweep.ps1 CHANGED
@@ -1,316 +1,322 @@
1
- # windowsweep - safe, developer-aware Windows cleanup CLI. Entry point.
2
- #
3
- # windowsweep.ps1 (guided walkthrough)
4
- # windowsweep.ps1 --help (every mode and option)
5
- #
6
- # Runs on Windows PowerShell 5.1 and PowerShell 7. Flags are parsed from $args (no param block) so
7
- # GNU-style options survive `powershell -File`. Source is ASCII-only by design.
8
- #
9
- # Author: Ahsan Mahmood <aoneahsan@gmail.com> https://aoneahsan.com
10
- # License: MIT (see LICENSE)
11
-
12
- $ErrorActionPreference = 'Continue'
13
- $Script:WS_ROOT = Split-Path -Parent $MyInvocation.MyCommand.Path
14
-
15
- $Script:WS = @{
16
- Mode = 'walkthrough'; RawArgs = @($args); ScriptPath = $MyInvocation.MyCommand.Path
17
- DryRun = $false; Yes = $false; Deep = $false; Elevate = $false; ElevatedChild = $false; BatchMode = $false
18
- Days = $null; TempDays = $null; LargeFileMb = $null; PurgeAll = $false
19
- DeveloperFlag = $null; ForgetDeveloper = $false; Developer = $null; DeveloperSource = ''
20
- ScanRoots = @(); ExcludePaths = @(); Hiberfil = ''; ResetBase = $false; Permanent = $false
21
- OnlyList = ''; Profile = ''; Exclude = ''; ExportFmt = 'both'; ExportId = 'latest'; PruneDays = 90
22
- LogsDir = ''; ReportsDir = ''; NoReport = $false; CleanupLogs = $false
23
- JsonMode = $false; Quiet = $false; NoColor = $false; Ascii = $false; Notify = $false
24
- # Scripted selection (--select / --select-file): a person's choice made in advance. SelectActive is LATCHED
25
- # once at parse time because SelectQueue empties as prompts consume it.
26
- SelectQueue = @(); SelectPaths = @(); SelectActive = $false; LastSelectionScripted = $false
27
- Candidates = @(); ScanTargets = @()
28
- TotalFreed = [long]0; TotalEstimated = [long]0; SectionFreed = [long]0
29
- Refusals = @(); Hints = @(); ExitCode = 0; Finished = $false; AllowOwnData = $false
30
- }
31
-
32
- foreach ($lib in @('constants', 'ui', 'log', 'fs', 'safety', 'config', 'scan', 'actions')) {
33
- . (Join-Path $Script:WS_ROOT "lib\$lib.ps1")
34
- }
35
- foreach ($m in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' | Sort-Object Name)) {
36
- . $m.FullName
37
- }
38
-
39
- function Show-Usage {
40
- $v = Get-ToolVersion
41
- $text = @"
42
- $Script:WS_NAME v$v - safe, developer-aware Windows cleanup
43
-
44
- USAGE
45
- windowsweep [mode] [options]
46
-
47
- MODES (default = guided walkthrough through every category)
48
- -w, --walkthrough Guided walkthrough, asks before every step
49
- -m, --menu Jump-to menu - run one section at a time
50
- -a, --all Safe batch: sections $($Script:WS_SAFE_BATCH -join ',') (+12,13 when elevated)
51
- -s, --scan Read-only: every target with its size, nothing deleted
52
- --only L Run exactly these sections, e.g. --only 1,3,5-7
53
- --profile NAME dev | minimal | cache-only | system | deep | audit
54
- --exclude L Drop sections from --all / --profile
55
- --list Print the section catalogue and exit
56
- --list-targets Print every path the tool can touch (read-only)
57
- --self-test Verify syntax, safety guards, dry-run and junction handling
58
- --reports Reports manager: list / view / export past runs
59
- --export F [ID] Export a report: F = md | html | both, ID = N | latest | all
60
- --stats Run history from ~\.windowsweep
61
- --prune-history N Delete logs + reports older than N days (default 90)
62
- --feedback How to report a bug (offline; nothing is sent)
63
- --report-issue Open a pre-filled GitHub issue in your browser (you review it first)
64
- --debug-bundle Zip the latest log + report for a bug report
65
- --install-task Weekly Scheduled Task (Sunday 03:00, safe batch) --uninstall-task
66
- --install-alias Add a 'cleanup' function to your PowerShell profile --uninstall-alias
67
- --uninstall-data Remove ~\.windowsweep (logs, reports, config) after confirming
68
- -V, --version Version and author -h, --help This text
69
-
70
- OPTIONS
71
- --dry-run Delete nothing; show what would go and how much it frees
72
- -y, --yes Auto-confirm regenerable-cache steps (never personal files)
73
- --i-understand-deep Allow deep sections (11 Recycle Bin, 15 hibernation, 16 event logs,
74
- 20 disk-image compaction) to run unattended with --yes
75
- --elevate Relaunch elevated (UAC prompt) so admin sections can run
76
- -d, --days N Idle threshold for caches (default 100). A file goes only when its newest
77
- timestamp (write, access, creation) is at least N days old
78
- --temp-days N Idle threshold for %TEMP% (default 3)
79
- --purge-all Clear cache targets completely instead of pruning idle files
80
- --developer / --not-developer Override the saved developer answer for this run
81
- --forget-developer Ask the developer question again
82
- --scan-roots "P1;P2" Project roots for section 17 --exclude-path P (repeatable)
83
- --large-file-mb N Minimum size for section 19 (default 100)
84
- --hiberfil off|reduced|keep What section 15 does --reset-base DISM /ResetBase in 14
85
- --permanent Sections 18/19 delete instead of using the Recycle Bin
86
- --select L Pre-answer the next interactive selection, e.g. --select 1,3-5. Repeatable: the
87
- lists are consumed in the order the prompts appear
88
- --select-file P UTF-8 file of one full path per line, matched against each prompt's candidates.
89
- Either flag lets sections 17/18/19/23 run unattended - a person did choose
90
- --notify Show a Windows notification when the run ends
91
- --logs-dir P / --reports-dir P / --no-report / --cleanup-logs
92
- --json One-line JSON summary on stdout (everything else on stderr)
93
- -q, --quiet Less chatter --no-color --ascii (plain glyphs)
94
-
95
- SAFETY
96
- Every deletion passes one chokepoint that refuses drive roots, Windows, Program Files, your
97
- profile root, Documents/Pictures/Desktop, credentials, toolchains and browser/editor state, and
98
- that never follows a junction or symlink. Personal-file sections are interactive only and use the
99
- Recycle Bin. --scan and --dry-run change nothing. No network calls, ever.
100
-
101
- OUTPUT
102
- Logs: $($Script:WS.LogsDir)
103
- Reports: $($Script:WS.ReportsDir)
104
-
105
- $Script:WS_NAME v$v by $Script:WS_AUTHOR <$Script:WS_EMAIL> $Script:WS_WEB
106
- $Script:WS_REPO - $Script:WS_LICENSE
107
- "@
108
- Write-Host $text
109
- }
110
-
111
- function Get-NextArg {
112
- param([string[]] $Argv, [ref] $Index, [string] $Name)
113
- $Index.Value++
114
- if ($Index.Value -ge $Argv.Count) { throw "option $Name needs a value" }
115
- return [string]$Argv[$Index.Value]
116
- }
117
-
118
- function Read-Arguments {
119
- $ws = $Script:WS
120
- $argv = @($ws.RawArgs)
121
- $i = 0
122
- while ($i -lt $argv.Count) {
123
- $a = [string]$argv[$i]
124
- $inline = $null
125
- if ($a -match '^(--[A-Za-z][A-Za-z0-9-]*)=(.*)$') { $a = $Matches[1]; $inline = $Matches[2] }
126
- switch ($a) {
127
- { $_ -in '-w', '--walkthrough' } { $ws.Mode = 'walkthrough' }
128
- { $_ -in '-m', '--menu', '-i', '--interactive' } { $ws.Mode = 'menu' }
129
- { $_ -in '-a', '--all', '--all-safe' } { $ws.Mode = 'all'; $ws.BatchMode = $true }
130
- { $_ -in '-s', '--scan' } { $ws.Mode = 'scan' }
131
- '--only' { $ws.Mode = 'only'; $ws.BatchMode = $true; if ($null -ne $inline) { $ws.OnlyList = $inline } else { $ws.OnlyList = Get-NextArg $argv ([ref]$i) $a } }
132
- '--profile' { $ws.Mode = 'only'; $ws.BatchMode = $true; if ($null -ne $inline) { $ws.Profile = $inline } else { $ws.Profile = Get-NextArg $argv ([ref]$i) $a } }
133
- '--exclude' { if ($null -ne $inline) { $ws.Exclude = $inline } else { $ws.Exclude = Get-NextArg $argv ([ref]$i) $a } }
134
- '--list' { $ws.Mode = 'list' }
135
- '--list-targets' { $ws.Mode = 'list_targets' }
136
- '--self-test' { $ws.Mode = 'self_test' }
137
- '--reports' { $ws.Mode = 'reports' }
138
- '--export' {
139
- $ws.Mode = 'export'
140
- if ($null -ne $inline) { $ws.ExportFmt = $inline } elseif (($i + 1) -lt $argv.Count -and -not ([string]$argv[$i + 1]).StartsWith('-')) { $i++; $ws.ExportFmt = [string]$argv[$i] }
141
- if (($i + 1) -lt $argv.Count -and -not ([string]$argv[$i + 1]).StartsWith('-')) { $i++; $ws.ExportId = [string]$argv[$i] }
142
- }
143
- '--stats' { $ws.Mode = 'stats' }
144
- '--prune-history' {
145
- $ws.Mode = 'prune_history'
146
- if ($null -ne $inline) { $ws.PruneDays = [int]$inline } elseif (($i + 1) -lt $argv.Count -and ([string]$argv[$i + 1]) -match '^\d+$') { $i++; $ws.PruneDays = [int]$argv[$i] }
147
- }
148
- '--feedback' { $ws.Mode = 'feedback' }
149
- { $_ -in '--report-issue', '--report-bug' } { $ws.Mode = 'report_issue' }
150
- '--debug-bundle' { $ws.Mode = 'debug_bundle' }
151
- '--install-task' { $ws.Mode = 'install_task' }
152
- '--uninstall-task' { $ws.Mode = 'uninstall_task' }
153
- '--install-alias' { $ws.Mode = 'install_alias' }
154
- '--uninstall-alias' { $ws.Mode = 'uninstall_alias' }
155
- '--uninstall-data' { $ws.Mode = 'uninstall_data' }
156
- { $_ -in '-V', '--version' } { $ws.Mode = 'version' }
157
- { $_ -in '-h', '--help', '/?', '-?' } { $ws.Mode = 'help' }
158
- { $_ -in '-n', '--dry-run' } { $ws.DryRun = $true }
159
- { $_ -in '-y', '--yes' } { $ws.Yes = $true }
160
- '--i-understand-deep' { $ws.Deep = $true }
161
- '--elevate' { $ws.Elevate = $true }
162
- '--elevated-child' { $ws.ElevatedChild = $true }
163
- { $_ -in '-d', '--days' } { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--days needs a whole number, got '$v'" }; $ws.Days = [int]$v }
164
- '--temp-days' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--temp-days needs a whole number, got '$v'" }; $ws.TempDays = [int]$v }
165
- '--purge-all' { $ws.PurgeAll = $true }
166
- '--developer' { $ws.DeveloperFlag = $true }
167
- { $_ -in '--not-developer', '--no-developer' } { $ws.DeveloperFlag = $false }
168
- '--forget-developer' { $ws.ForgetDeveloper = $true }
169
- '--scan-roots' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ScanRoots = @($v -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
170
- '--exclude-path' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ExcludePaths += $v }
171
- '--large-file-mb' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--large-file-mb needs a whole number, got '$v'" }; $ws.LargeFileMb = [int]$v }
172
- '--hiberfil' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notin 'off', 'reduced', 'keep') { throw "--hiberfil must be off, reduced or keep" }; $ws.Hiberfil = $v }
173
- '--reset-base' { $ws.ResetBase = $true }
174
- '--permanent' { $ws.Permanent = $true }
175
- '--logs-dir' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.LogsDir = $v }
176
- '--reports-dir' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ReportsDir = $v }
177
- '--no-report' { $ws.NoReport = $true }
178
- '--cleanup-logs' { $ws.CleanupLogs = $true }
179
- '--notify' { $ws.Notify = $true }
180
- '--select' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.SelectQueue += $v }
181
- '--select-file' {
182
- $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }
183
- $f = Get-FullPath $v
184
- if (-not $f -or -not (Test-Path -LiteralPath $f)) { throw "--select-file not found: $v" }
185
- $ws.SelectPaths = @([IO.File]::ReadAllLines($f) | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
186
- if ($ws.SelectPaths.Count -eq 0) { throw "--select-file has no paths in it: $v" }
187
- }
188
- '--json' { $ws.JsonMode = $true; $ws.Quiet = $true }
189
- { $_ -in '-q', '--quiet' } { $ws.Quiet = $true }
190
- '--no-color' { $ws.NoColor = $true }
191
- '--ascii' { $ws.Ascii = $true }
192
- '--pwsh' { }
193
- default { throw "unknown argument: $a" }
194
- }
195
- $i++
196
- }
197
- if ($ws.Profile -and -not $Script:WS_PROFILES.ContainsKey($ws.Profile)) {
198
- throw "unknown profile '$($ws.Profile)' (known: $($Script:WS_PROFILES.Keys -join ', '))"
199
- }
200
- # Latched here, once: the queue is drained by the prompts, so a recomputed flag would flip to false mid-run.
201
- $ws.SelectActive = ($ws.SelectQueue.Count -gt 0 -or $ws.SelectPaths.Count -gt 0)
202
- }
203
-
204
- function Initialize-Paths {
205
- $ws = $Script:WS
206
- $ws.Stamp = (Get-Date -Format 'yyyy-MM-dd_HHmmss') + '-' + $PID
207
- $dataHome = $env:WINDOWSWEEP_HOME
208
- if ([string]::IsNullOrWhiteSpace($dataHome)) { $dataHome = Join-Path $env:USERPROFILE ".$Script:WS_NAME" }
209
- $ws.Home = Get-FullPath $dataHome
210
- if (-not $ws.LogsDir) { $ws.LogsDir = $env:WINDOWSWEEP_LOG_DIR }
211
- if (-not $ws.LogsDir) { $ws.LogsDir = Join-Path $ws.Home 'logs' }
212
- if (-not $ws.ReportsDir) { $ws.ReportsDir = $env:WINDOWSWEEP_REPORTS_DIR }
213
- if (-not $ws.ReportsDir) { $ws.ReportsDir = Join-Path $ws.Home 'reports' }
214
- $ws.LogsDir = Get-FullPath $ws.LogsDir
215
- $ws.ReportsDir = Get-FullPath $ws.ReportsDir
216
- $ws.FeedbackDir = Join-Path $ws.Home 'feedback'
217
- $ws.ConfigPath = Join-Path $ws.Home 'config.json'
218
- $ws.Launcher = $env:WINDOWSWEEP_LAUNCHER
219
- if (-not $ws.Launcher) { $ws.Launcher = 'powershell' }
220
- # Well-known roots used by every module. LL = LocalLow, SR = SystemRoot, PD = ProgramData, SD = system drive.
221
- $Script:P = @{
222
- U = (Get-FullPath $env:USERPROFILE); L = (Get-FullPath $env:LOCALAPPDATA); A = (Get-FullPath $env:APPDATA)
223
- LL = (Get-FullPath (Join-Path (Split-Path -Parent $env:LOCALAPPDATA) 'LocalLow')); SR = (Get-FullPath $env:SystemRoot)
224
- PD = (Get-FullPath $env:ProgramData); SD = $env:SystemDrive; TEMP = (Get-FullPath $env:TEMP); TMP = (Get-FullPath $env:TMP)
225
- PF = (Get-FullPath $env:ProgramFiles); PF86 = (Get-FullPath ${env:ProgramFiles(x86)})
226
- }
227
- $ws.Npx = ($env:WINDOWSWEEP_NPX -eq '1')
228
- $ws.IsAdmin = Test-IsAdmin
229
- try { $ws.OsCaption = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).Caption } catch { $ws.OsCaption = 'Windows' }
230
- }
231
-
232
- function Initialize-Settings {
233
- <# .SYNOPSIS Merge config defaults under explicit flags. #>
234
- $ws = $Script:WS
235
- $cfg = Import-Config
236
- if ($null -eq $ws.Days) { $ws.Days = [int]$cfg.days }
237
- if ($null -eq $ws.TempDays) { $ws.TempDays = [int]$cfg.tempDays }
238
- if ($null -eq $ws.LargeFileMb) { $ws.LargeFileMb = [int]$cfg.largeFileMb }
239
- if ($ws.ScanRoots.Count -eq 0 -and $cfg.scanRoots) { $ws.ScanRoots = @($cfg.scanRoots) }
240
- if ($cfg.excludePaths) { $ws.ExcludePaths = @($ws.ExcludePaths) + @($cfg.excludePaths) }
241
- }
242
-
243
- function Invoke-Main {
244
- $ws = $Script:WS
245
- switch ($ws.Mode) {
246
- 'help' { Show-Usage; return }
247
- 'version' { Show-Version; return }
248
- 'list' { Show-SectionList; return }
249
- 'list_targets' { Show-TargetList; return }
250
- 'self_test' { $ws.ExitCode = Invoke-SelfTest; return }
251
- 'scan' { Invoke-ScanMode; return }
252
- 'all' { Invoke-BatchMode; return }
253
- 'only' { Invoke-BatchMode; return }
254
- 'walkthrough' { Invoke-Walkthrough; return }
255
- 'menu' { Invoke-Menu; return }
256
- 'reports' { Invoke-ReportsManager; return }
257
- 'export' { $ws.ExitCode = Export-Reports -Format $ws.ExportFmt -Id $ws.ExportId; return }
258
- 'stats' { Show-Stats; return }
259
- 'prune_history' { Remove-OldHistory -Days $ws.PruneDays; return }
260
- 'feedback' { Show-Feedback; return }
261
- 'report_issue' { Invoke-ReportIssue; return }
262
- 'debug_bundle' { New-DebugBundle; return }
263
- 'install_task' { Install-WeeklyTask; return }
264
- 'uninstall_task' { Uninstall-WeeklyTask; return }
265
- 'install_alias' { Install-ProfileAlias; return }
266
- 'uninstall_alias' { Uninstall-ProfileAlias; return }
267
- 'uninstall_data' { Remove-ToolData; return }
268
- default { Write-Err "unknown mode: $($ws.Mode)"; $ws.ExitCode = $Script:WS_EXIT_USAGE }
269
- }
270
- }
271
-
272
- # ---------------------------------------------------------------------------------------------
273
- # Bootstrap
274
- # ---------------------------------------------------------------------------------------------
275
- try {
276
- Read-Arguments
277
- } catch {
278
- [Console]::Error.WriteLine("windowsweep: $($_.Exception.Message)")
279
- [Console]::Error.WriteLine("try: windowsweep --help")
280
- exit 2
281
- }
282
- Initialize-Paths
283
- Initialize-Ui
284
- Initialize-Safety
285
- $quietModes = @('help', 'version', 'list')
286
- if ($Script:WS.Mode -notin $quietModes) { Initialize-Log }
287
- Initialize-Settings
288
- Initialize-Report
289
- Initialize-CrashTrap
290
-
291
- if ($Script:WS.Elevate -and -not $Script:WS.IsAdmin) {
292
- $code = Invoke-Elevated
293
- Restore-Ui
294
- exit $code
295
- }
296
- if ($Script:WS.Elevate -and $Script:WS.IsAdmin) { Write-Note 'already elevated; --elevate is a no-op' }
297
-
298
- if ($Script:WS.Mode -in @('walkthrough', 'menu', 'all', 'only')) { Resolve-DeveloperMode; Confirm-PurgeAllOnce }
299
-
300
- try {
301
- Invoke-Main
302
- $Script:WS.Finished = $true
303
- } catch {
304
- Invoke-CrashBundle -ErrorRecord $_
305
- $Script:WS.ExitCode = $Script:WS_EXIT_FAIL
306
- } finally {
307
- Restore-Ui
308
- if ($Script:WS.CleanupLogs -and $Script:WS.LogFile -and (Test-Path -LiteralPath $Script:WS.LogFile)) {
309
- try { Remove-Item -LiteralPath $Script:WS.LogFile -Force -ErrorAction SilentlyContinue } catch { $null = $_ }
310
- }
311
- if (-not $Script:WS.Finished -and $Script:WS.ExitCode -eq 0) {
312
- Write-LogLine 'run interrupted before it finished (Ctrl-C or host stop) - exit 130'
313
- exit $Script:WS_EXIT_INTERRUPT
314
- }
315
- }
316
- exit $Script:WS.ExitCode
1
+ # windowsweep - safe, developer-aware Windows cleanup CLI. Entry point.
2
+ #
3
+ # windowsweep.ps1 (guided walkthrough)
4
+ # windowsweep.ps1 --help (every mode and option)
5
+ #
6
+ # Runs on Windows PowerShell 5.1 and PowerShell 7. Flags are parsed from $args (no param block) so
7
+ # GNU-style options survive `powershell -File`. Source is ASCII-only by design.
8
+ #
9
+ # Author: Ahsan Mahmood <aoneahsan@gmail.com> https://aoneahsan.com
10
+ # License: MIT (see LICENSE)
11
+
12
+ $ErrorActionPreference = 'Continue'
13
+ $Script:WS_ROOT = Split-Path -Parent $MyInvocation.MyCommand.Path
14
+
15
+ $Script:WS = @{
16
+ Mode = 'walkthrough'; RawArgs = @($args); ScriptPath = $MyInvocation.MyCommand.Path
17
+ DryRun = $false; Yes = $false; Deep = $false; Elevate = $false; ElevatedChild = $false; BatchMode = $false
18
+ Days = $null; TempDays = $null; LargeFileMb = $null; PurgeAll = $false
19
+ DeveloperFlag = $null; ForgetDeveloper = $false; Developer = $null; DeveloperSource = ''
20
+ ScanRoots = @(); ExcludePaths = @(); Excluded = @(); Hiberfil = ''; ResetBase = $false; Permanent = $false
21
+ OnlyList = ''; Profile = ''; Exclude = ''; ExportFmt = 'both'; ExportId = 'latest'; PruneDays = 90
22
+ LogsDir = ''; ReportsDir = ''; NoReport = $false; CleanupLogs = $false
23
+ JsonMode = $false; Quiet = $false; NoColor = $false; Ascii = $false; Notify = $false
24
+ # Scripted selection (--select / --select-file): a person's choice made in advance. SelectActive is LATCHED
25
+ # once at parse time because SelectQueue empties as prompts consume it.
26
+ SelectQueue = @(); SelectPaths = @(); SelectActive = $false; LastSelectionScripted = $false
27
+ Candidates = @(); ScanTargets = @()
28
+ TotalFreed = [long]0; TotalEstimated = [long]0; SectionFreed = [long]0
29
+ Refusals = @(); Hints = @(); ExitCode = 0; Finished = $false; AllowOwnData = $false
30
+ }
31
+
32
+ foreach ($lib in @('constants', 'ui', 'log', 'fs', 'safety', 'external', 'config', 'scan', 'actions')) {
33
+ . (Join-Path $Script:WS_ROOT "lib\$lib.ps1")
34
+ }
35
+ foreach ($m in (Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' | Sort-Object Name)) {
36
+ . $m.FullName
37
+ }
38
+
39
+ function Show-Usage {
40
+ $v = Get-ToolVersion
41
+ $text = @"
42
+ $Script:WS_NAME v$v - names every path before it touches one
43
+
44
+ USAGE
45
+ windowsweep [mode] [options]
46
+
47
+ MODES (default = the guided walkthrough)
48
+ -w, --walkthrough Guided walkthrough, asks before every step
49
+ -m, --menu Jump-to menu - run one section at a time
50
+ -a, --all Safe batch: sections $($Script:WS_SAFE_BATCH -join ',') (+$($Script:WS_SAFE_BATCH_ADMIN -join ',') when elevated)
51
+ -s, --scan Every target with its size; nothing is deleted
52
+ --only L Run exactly these sections, e.g. --only 1,3,5-7
53
+ --profile NAME dev | minimal | cache-only | system | deep | audit
54
+ --exclude L Drop sections from --all / --profile
55
+ --list Print the section catalogue and exit
56
+ --list-targets Print every path the tool can touch (read-only)
57
+ --self-test Verify syntax, safety guards, dry-run and junction handling
58
+ --reports Reports manager: list / view / export past runs
59
+ --export F [ID] Export a report: F = md | html | both, ID = N | latest | all
60
+ --stats Run history from ~\.windowsweep
61
+ --prune-history N Delete logs + reports older than N days (default 90)
62
+ --feedback How to report a bug (offline; nothing is sent)
63
+ --report-issue Open a pre-filled GitHub issue in your browser (you review it first)
64
+ --debug-bundle Zip the latest log + report for a bug report
65
+ --install-task Weekly Scheduled Task (Sunday 03:00, safe batch) --uninstall-task
66
+ --install-alias Add a 'cleanup' function to your PowerShell profile --uninstall-alias
67
+ --uninstall-data Remove ~\.windowsweep (logs, reports, config) after confirming
68
+ -V, --version Version and author -h, --help This text
69
+
70
+ OPTIONS
71
+ --dry-run Delete nothing; show what would go and how much it frees
72
+ -y, --yes Auto-confirm regenerable-cache steps (never personal files)
73
+ --i-understand-deep Allow deep sections (11 Recycle Bin, 15 hibernation, 16 event logs,
74
+ 20 disk-image compaction) to run unattended with --yes
75
+ --elevate Relaunch elevated (UAC prompt) so admin sections can run
76
+ -d, --days N Idle threshold for caches (default 100). A file goes only when its newest
77
+ timestamp (write, access, creation) is at least N days old
78
+ --temp-days N Idle threshold for %TEMP% (default 3)
79
+ --purge-all Clear cache targets completely instead of pruning idle files
80
+ --developer / --not-developer Override the saved developer answer for this run
81
+ --forget-developer Ask the developer question again
82
+ --scan-roots "P1;P2" Project roots for section 17 --exclude-path P (repeatable, all sections)
83
+ --large-file-mb N Minimum size for section 19 (default 100)
84
+ --hiberfil off|reduced|keep What section 15 does --reset-base DISM /ResetBase in 14
85
+ --permanent Sections 18, 19 and 23 delete instead of using the Recycle Bin
86
+ --select L Pre-answer the next interactive selection, e.g. --select 1,3-5. Repeatable: the
87
+ lists are consumed in the order the prompts appear
88
+ --select-file P UTF-8 file of one full path per line, matched against each prompt's candidates.
89
+ Either flag lets sections 17/18/19/23 run unattended - a person did choose
90
+ --notify Show a Windows notification when the run ends
91
+ --logs-dir P / --reports-dir P / --no-report / --cleanup-logs
92
+ --json One-line JSON summary on stdout (everything else on stderr)
93
+ -q, --quiet Less chatter --no-color --ascii (plain glyphs)
94
+
95
+ SAFETY
96
+ Every deletion passes one chokepoint that refuses drive roots, Windows, Program Files, your
97
+ profile root, Documents/Pictures/Desktop, credentials, toolchains and browser/editor state, and
98
+ that never follows a junction or symlink. Sections 17, 18, 19 and 23 ask you to pick item by
99
+ item; 18, 19 and 23 send what you pick to the Recycle Bin. --scan and --dry-run delete nothing,
100
+ though both still write a log and a report. No network calls, ever.
101
+
102
+ OUTPUT
103
+ Logs: $($Script:WS.LogsDir)
104
+ Reports: $($Script:WS.ReportsDir)
105
+
106
+ $Script:WS_NAME v$v by $Script:WS_AUTHOR <$Script:WS_EMAIL> $Script:WS_WEB
107
+ $Script:WS_REPO - $Script:WS_LICENSE
108
+ "@
109
+ Write-Host $text
110
+ }
111
+
112
+ function Get-NextArg {
113
+ param([string[]] $Argv, [ref] $Index, [string] $Name)
114
+ $Index.Value++
115
+ if ($Index.Value -ge $Argv.Count) { throw "option $Name needs a value" }
116
+ return [string]$Argv[$Index.Value]
117
+ }
118
+
119
+ function Read-Arguments {
120
+ $ws = $Script:WS
121
+ $argv = @($ws.RawArgs)
122
+ $i = 0
123
+ while ($i -lt $argv.Count) {
124
+ $a = [string]$argv[$i]
125
+ $inline = $null
126
+ if ($a -match '^(--[A-Za-z][A-Za-z0-9-]*)=(.*)$') { $a = $Matches[1]; $inline = $Matches[2] }
127
+ switch ($a) {
128
+ { $_ -in '-w', '--walkthrough' } { $ws.Mode = 'walkthrough' }
129
+ { $_ -in '-m', '--menu', '-i', '--interactive' } { $ws.Mode = 'menu' }
130
+ { $_ -in '-a', '--all', '--all-safe' } { $ws.Mode = 'all'; $ws.BatchMode = $true }
131
+ { $_ -in '-s', '--scan' } { $ws.Mode = 'scan' }
132
+ '--only' { $ws.Mode = 'only'; $ws.BatchMode = $true; if ($null -ne $inline) { $ws.OnlyList = $inline } else { $ws.OnlyList = Get-NextArg $argv ([ref]$i) $a } }
133
+ '--profile' { $ws.Mode = 'only'; $ws.BatchMode = $true; if ($null -ne $inline) { $ws.Profile = $inline } else { $ws.Profile = Get-NextArg $argv ([ref]$i) $a } }
134
+ '--exclude' { if ($null -ne $inline) { $ws.Exclude = $inline } else { $ws.Exclude = Get-NextArg $argv ([ref]$i) $a } }
135
+ '--list' { $ws.Mode = 'list' }
136
+ '--list-targets' { $ws.Mode = 'list_targets' }
137
+ '--self-test' { $ws.Mode = 'self_test' }
138
+ '--reports' { $ws.Mode = 'reports' }
139
+ '--export' {
140
+ $ws.Mode = 'export'
141
+ if ($null -ne $inline) { $ws.ExportFmt = $inline } elseif (($i + 1) -lt $argv.Count -and -not ([string]$argv[$i + 1]).StartsWith('-')) { $i++; $ws.ExportFmt = [string]$argv[$i] }
142
+ if (($i + 1) -lt $argv.Count -and -not ([string]$argv[$i + 1]).StartsWith('-')) { $i++; $ws.ExportId = [string]$argv[$i] }
143
+ }
144
+ '--stats' { $ws.Mode = 'stats' }
145
+ '--prune-history' {
146
+ $ws.Mode = 'prune_history'
147
+ if ($null -ne $inline) { $ws.PruneDays = [int]$inline } elseif (($i + 1) -lt $argv.Count -and ([string]$argv[$i + 1]) -match '^\d+$') { $i++; $ws.PruneDays = [int]$argv[$i] }
148
+ }
149
+ '--feedback' { $ws.Mode = 'feedback' }
150
+ { $_ -in '--report-issue', '--report-bug' } { $ws.Mode = 'report_issue' }
151
+ '--debug-bundle' { $ws.Mode = 'debug_bundle' }
152
+ '--install-task' { $ws.Mode = 'install_task' }
153
+ '--uninstall-task' { $ws.Mode = 'uninstall_task' }
154
+ '--install-alias' { $ws.Mode = 'install_alias' }
155
+ '--uninstall-alias' { $ws.Mode = 'uninstall_alias' }
156
+ '--uninstall-data' { $ws.Mode = 'uninstall_data' }
157
+ { $_ -in '-V', '--version' } { $ws.Mode = 'version' }
158
+ { $_ -in '-h', '--help', '/?', '-?' } { $ws.Mode = 'help' }
159
+ { $_ -in '-n', '--dry-run' } { $ws.DryRun = $true }
160
+ { $_ -in '-y', '--yes' } { $ws.Yes = $true }
161
+ '--i-understand-deep' { $ws.Deep = $true }
162
+ '--elevate' { $ws.Elevate = $true }
163
+ '--elevated-child' { $ws.ElevatedChild = $true }
164
+ { $_ -in '-d', '--days' } { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--days needs a whole number, got '$v'" }; $ws.Days = [int]$v }
165
+ '--temp-days' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--temp-days needs a whole number, got '$v'" }; $ws.TempDays = [int]$v }
166
+ '--purge-all' { $ws.PurgeAll = $true }
167
+ '--developer' { $ws.DeveloperFlag = $true }
168
+ { $_ -in '--not-developer', '--no-developer' } { $ws.DeveloperFlag = $false }
169
+ '--forget-developer' { $ws.ForgetDeveloper = $true }
170
+ '--scan-roots' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ScanRoots = @($v -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
171
+ '--exclude-path' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ExcludePaths += $v }
172
+ '--large-file-mb' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notmatch '^\d+$') { throw "--large-file-mb needs a whole number, got '$v'" }; $ws.LargeFileMb = [int]$v }
173
+ '--hiberfil' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; if ($v -notin 'off', 'reduced', 'keep') { throw "--hiberfil must be off, reduced or keep" }; $ws.Hiberfil = $v }
174
+ '--reset-base' { $ws.ResetBase = $true }
175
+ '--permanent' { $ws.Permanent = $true }
176
+ '--logs-dir' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.LogsDir = $v }
177
+ '--reports-dir' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ReportsDir = $v }
178
+ '--no-report' { $ws.NoReport = $true }
179
+ '--cleanup-logs' { $ws.CleanupLogs = $true }
180
+ '--notify' { $ws.Notify = $true }
181
+ '--select' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.SelectQueue += $v }
182
+ '--select-file' {
183
+ $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }
184
+ $f = Get-FullPath $v
185
+ if (-not $f -or -not (Test-Path -LiteralPath $f)) { throw "--select-file not found: $v" }
186
+ $ws.SelectPaths = @([IO.File]::ReadAllLines($f) | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
187
+ if ($ws.SelectPaths.Count -eq 0) { throw "--select-file has no paths in it: $v" }
188
+ }
189
+ '--json' { $ws.JsonMode = $true; $ws.Quiet = $true }
190
+ { $_ -in '-q', '--quiet' } { $ws.Quiet = $true }
191
+ '--no-color' { $ws.NoColor = $true }
192
+ '--ascii' { $ws.Ascii = $true }
193
+ '--pwsh' { }
194
+ default { throw "unknown argument: $a" }
195
+ }
196
+ $i++
197
+ }
198
+ if ($ws.Profile -and -not $Script:WS_PROFILES.ContainsKey($ws.Profile)) {
199
+ throw "unknown profile '$($ws.Profile)' (known: $($Script:WS_PROFILES.Keys -join ', '))"
200
+ }
201
+ # Latched here, once: the queue is drained by the prompts, so a recomputed flag would flip to false mid-run.
202
+ $ws.SelectActive = ($ws.SelectQueue.Count -gt 0 -or $ws.SelectPaths.Count -gt 0)
203
+ }
204
+
205
+ function Initialize-Paths {
206
+ $ws = $Script:WS
207
+ $ws.Stamp = (Get-Date -Format 'yyyy-MM-dd_HHmmss') + '-' + $PID
208
+ $dataHome = $env:WINDOWSWEEP_HOME
209
+ if ([string]::IsNullOrWhiteSpace($dataHome)) { $dataHome = Join-Path $env:USERPROFILE ".$Script:WS_NAME" }
210
+ $ws.Home = Get-FullPath $dataHome
211
+ if (-not $ws.LogsDir) { $ws.LogsDir = $env:WINDOWSWEEP_LOG_DIR }
212
+ if (-not $ws.LogsDir) { $ws.LogsDir = Join-Path $ws.Home 'logs' }
213
+ if (-not $ws.ReportsDir) { $ws.ReportsDir = $env:WINDOWSWEEP_REPORTS_DIR }
214
+ if (-not $ws.ReportsDir) { $ws.ReportsDir = Join-Path $ws.Home 'reports' }
215
+ $ws.LogsDir = Get-FullPath $ws.LogsDir
216
+ $ws.ReportsDir = Get-FullPath $ws.ReportsDir
217
+ $ws.FeedbackDir = Join-Path $ws.Home 'feedback'
218
+ $ws.ConfigPath = Join-Path $ws.Home 'config.json'
219
+ $ws.Launcher = $env:WINDOWSWEEP_LAUNCHER
220
+ if (-not $ws.Launcher) { $ws.Launcher = 'powershell' }
221
+ # Well-known roots used by every module. LL = LocalLow, SR = SystemRoot, PD = ProgramData, SD = system drive.
222
+ $Script:P = @{
223
+ U = (Get-FullPath $env:USERPROFILE); L = (Get-FullPath $env:LOCALAPPDATA); A = (Get-FullPath $env:APPDATA)
224
+ LL = (Get-FullPath (Join-Path (Split-Path -Parent $env:LOCALAPPDATA) 'LocalLow')); SR = (Get-FullPath $env:SystemRoot)
225
+ PD = (Get-FullPath $env:ProgramData); SD = $env:SystemDrive; TEMP = (Get-FullPath $env:TEMP); TMP = (Get-FullPath $env:TMP)
226
+ PF = (Get-FullPath $env:ProgramFiles); PF86 = (Get-FullPath ${env:ProgramFiles(x86)})
227
+ }
228
+ $ws.Npx = ($env:WINDOWSWEEP_NPX -eq '1')
229
+ $ws.IsAdmin = Test-IsAdmin
230
+ try { $ws.OsCaption = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).Caption } catch { $ws.OsCaption = 'Windows' }
231
+ }
232
+
233
+ function Initialize-Settings {
234
+ <# .SYNOPSIS Merge config defaults under explicit flags. #>
235
+ $ws = $Script:WS
236
+ $cfg = Import-Config
237
+ if ($null -eq $ws.Days) { $ws.Days = [int]$cfg.days }
238
+ if ($null -eq $ws.TempDays) { $ws.TempDays = [int]$cfg.tempDays }
239
+ if ($null -eq $ws.LargeFileMb) { $ws.LargeFileMb = [int]$cfg.largeFileMb }
240
+ if ($ws.ScanRoots.Count -eq 0 -and $cfg.scanRoots) { $ws.ScanRoots = @($cfg.scanRoots) }
241
+ if ($cfg.excludePaths) { $ws.ExcludePaths = @($ws.ExcludePaths) + @($cfg.excludePaths) }
242
+ # Must come after the merge above, or every config-file exclusion is silently dropped.
243
+ Initialize-Exclusions
244
+ }
245
+
246
+ function Invoke-Main {
247
+ $ws = $Script:WS
248
+ switch ($ws.Mode) {
249
+ 'help' { Show-Usage; return }
250
+ 'version' { Show-Version; return }
251
+ 'list' { Show-SectionList; return }
252
+ 'list_targets' { Show-TargetList; return }
253
+ 'self_test' { $ws.ExitCode = Invoke-SelfTest; return }
254
+ 'scan' { Invoke-ScanMode; return }
255
+ 'all' { Invoke-BatchMode; return }
256
+ 'only' { Invoke-BatchMode; return }
257
+ 'walkthrough' { Invoke-Walkthrough; return }
258
+ 'menu' { Invoke-Menu; return }
259
+ 'reports' { Invoke-ReportsManager; return }
260
+ 'export' { $ws.ExitCode = Export-Reports -Format $ws.ExportFmt -Id $ws.ExportId; return }
261
+ 'stats' { Show-Stats; return }
262
+ 'prune_history' { Remove-OldHistory -Days $ws.PruneDays; return }
263
+ 'feedback' { Show-Feedback; return }
264
+ 'report_issue' { Invoke-ReportIssue; return }
265
+ 'debug_bundle' { New-DebugBundle; return }
266
+ 'install_task' { Install-WeeklyTask; return }
267
+ 'uninstall_task' { Uninstall-WeeklyTask; return }
268
+ 'install_alias' { Install-ProfileAlias; return }
269
+ 'uninstall_alias' { Uninstall-ProfileAlias; return }
270
+ 'uninstall_data' { Remove-ToolData; return }
271
+ default { Write-Err "unknown mode: $($ws.Mode)"; $ws.ExitCode = $Script:WS_EXIT_USAGE }
272
+ }
273
+ }
274
+
275
+ # ---------------------------------------------------------------------------------------------
276
+ # Bootstrap
277
+ # ---------------------------------------------------------------------------------------------
278
+ try {
279
+ Read-Arguments
280
+ } catch {
281
+ [Console]::Error.WriteLine("windowsweep: $($_.Exception.Message)")
282
+ [Console]::Error.WriteLine("try: windowsweep --help")
283
+ exit 2
284
+ }
285
+ Initialize-Paths
286
+ Initialize-Ui
287
+ Initialize-Safety
288
+ $quietModes = @('help', 'version', 'list')
289
+ if ($Script:WS.Mode -notin $quietModes) { Initialize-Log }
290
+ Initialize-Settings
291
+ Initialize-Report
292
+ Initialize-CrashTrap
293
+
294
+ if ($Script:WS.Elevate -and -not $Script:WS.IsAdmin) {
295
+ $code = Invoke-Elevated
296
+ Restore-Ui
297
+ exit $code
298
+ }
299
+ if ($Script:WS.Elevate -and $Script:WS.IsAdmin) { Write-Note 'already elevated; --elevate is a no-op' }
300
+
301
+ if ($Script:WS.Mode -in @('walkthrough', 'menu', 'all', 'only')) { Resolve-DeveloperMode; Confirm-PurgeAllOnce }
302
+ # A scan deletes nothing, so it neither asks the developer question nor confirms a purge - but it must still
303
+ # REPORT the answer it was given. -ReadOnly takes the flag or the saved answer and stops there.
304
+ elseif ($Script:WS.Mode -eq 'scan') { Resolve-DeveloperMode -ReadOnly }
305
+
306
+ try {
307
+ Invoke-Main
308
+ $Script:WS.Finished = $true
309
+ } catch {
310
+ Invoke-CrashBundle -ErrorRecord $_
311
+ $Script:WS.ExitCode = $Script:WS_EXIT_FAIL
312
+ } finally {
313
+ Restore-Ui
314
+ if ($Script:WS.CleanupLogs -and $Script:WS.LogFile -and (Test-Path -LiteralPath $Script:WS.LogFile)) {
315
+ try { Remove-Item -LiteralPath $Script:WS.LogFile -Force -ErrorAction SilentlyContinue } catch { $null = $_ }
316
+ }
317
+ if (-not $Script:WS.Finished -and $Script:WS.ExitCode -eq 0) {
318
+ Write-LogLine 'run interrupted before it finished (Ctrl-C or host stop) - exit 130'
319
+ exit $Script:WS_EXIT_INTERRUPT
320
+ }
321
+ }
322
+ exit $Script:WS.ExitCode