windowsweep 1.0.0 → 1.1.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 +199 -0
- package/CHANGELOG.md +97 -0
- package/README.md +30 -17
- package/VERSION +1 -1
- package/lib/actions.ps1 +9 -6
- package/lib/config.ps1 +9 -0
- package/lib/constants.ps1 +10 -6
- package/lib/log.ps1 +1 -1
- package/lib/safety.ps1 +10 -10
- package/lib/scan.ps1 +10 -4
- package/lib/ui.ps1 +52 -9
- package/modules/crash_trap.ps1 +2 -2
- package/modules/docker.ps1 +1 -1
- package/modules/editors.ps1 +2 -1
- package/modules/globals_audit.ps1 +236 -0
- package/modules/health.ps1 +1 -1
- package/modules/installed_programs.ps1 +118 -0
- package/modules/menu.ps1 +3 -1
- package/modules/notify.ps1 +60 -0
- package/modules/orphaned_appdata.ps1 +189 -0
- package/modules/personal.ps1 +9 -7
- package/modules/pkg_managers.ps1 +4 -0
- package/modules/projects.ps1 +28 -8
- package/modules/release_helpers.ps1 +26 -2
- package/modules/runner.ps1 +56 -7
- package/modules/self_test_extra.ps1 +360 -0
- package/modules/startup_audit.ps1 +126 -0
- package/modules/windows_user.ps1 +6 -0
- package/package.json +3 -3
- package/windowsweep.ps1 +26 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# orphaned_appdata.ps1 - section 23: folders directly under %APPDATA% and %LOCALAPPDATA% that belong to no
|
|
2
|
+
# installed program. Interactive only; selected folders go to the Recycle Bin.
|
|
3
|
+
#
|
|
4
|
+
# The dangerous direction here is calling something orphaned when it is not, so every test errs the other
|
|
5
|
+
# way: matching is generous, the exclusion list is long, and an EMPTY program index produces ZERO candidates
|
|
6
|
+
# rather than "everything is orphaned".
|
|
7
|
+
|
|
8
|
+
# Windows' own folders, shared infrastructure, and vendors whose data outlives an uninstall.
|
|
9
|
+
$Script:WS_ORPHAN_EXCLUDE = @(
|
|
10
|
+
'Microsoft', 'Windows', 'WindowsPowerShell', 'PowerShell', 'Packages', 'Programs', 'Temp', 'Tmp', 'Comms',
|
|
11
|
+
'ConnectedDevicesPlatform', 'D3DSCache', 'Google', 'Mozilla', 'CrashDumps', 'VirtualStore', 'PeerDistRepub',
|
|
12
|
+
'Publishers', 'History', 'IconCache', 'ElevatedDiagnostics', 'Application Data', 'Local Settings',
|
|
13
|
+
'Deployment', 'assembly', 'GroupPolicy', 'Roaming', 'Local', 'LocalLow', 'Low', 'Diagnostics',
|
|
14
|
+
'PlaceholderTileLogoFolder', 'Desktop', 'Downloads', 'Documents', 'Start Menu', 'SendTo',
|
|
15
|
+
'Recent', 'NetHood', 'PrintHood', 'Templates', 'Cookies',
|
|
16
|
+
# Platform folders that belong to Windows or .NET rather than to any one program: they look orphaned
|
|
17
|
+
# because no uninstall entry names them, and one of them holds settings a .NET app wrote.
|
|
18
|
+
'IsolatedStorage', 'ToastNotificationManagerCompat', 'AppV', 'Package Cache', 'DBG', 'CLR_v4.0', 'CLR_v4.0_32'
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
function ConvertTo-MatchToken {
|
|
22
|
+
<# .SYNOPSIS Lower-case, alphanumerics only - so "Slack Technologies, Inc." and "slack" compare equal. #>
|
|
23
|
+
param([string] $Text)
|
|
24
|
+
if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
|
|
25
|
+
return ($Text.ToLowerInvariant() -replace '[^a-z0-9]', '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Add-MatchTokens {
|
|
29
|
+
<# .SYNOPSIS Add a display string to the token set: the whole thing, plus each word of 4+ characters. #>
|
|
30
|
+
param([System.Collections.Generic.HashSet[string]] $Set, [string] $Text)
|
|
31
|
+
$whole = ConvertTo-MatchToken $Text
|
|
32
|
+
if ($whole.Length -ge 3) { $null = $Set.Add($whole) }
|
|
33
|
+
foreach ($w in ($Text -split '[^A-Za-z0-9]+')) {
|
|
34
|
+
$t = ConvertTo-MatchToken $w
|
|
35
|
+
if ($t.Length -ge 4) { $null = $Set.Add($t) }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Get-InstalledProgramRows {
|
|
40
|
+
<# .SYNOPSIS Raw uninstall-hive rows from all three registry views. Shared with section 24. #>
|
|
41
|
+
$rows = @()
|
|
42
|
+
$hives = @(
|
|
43
|
+
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
|
|
44
|
+
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
|
|
45
|
+
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
|
|
46
|
+
)
|
|
47
|
+
foreach ($h in $hives) {
|
|
48
|
+
$keys = @()
|
|
49
|
+
try { $keys = @(Get-ChildItem -LiteralPath $h -ErrorAction Stop) } catch { $keys = @() }
|
|
50
|
+
foreach ($k in $keys) {
|
|
51
|
+
$props = $null
|
|
52
|
+
try { $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction Stop } catch { continue }
|
|
53
|
+
if (-not $props.DisplayName) { continue }
|
|
54
|
+
$rows += [pscustomobject]@{
|
|
55
|
+
DisplayName = [string]$props.DisplayName; DisplayVersion = [string]$props.DisplayVersion
|
|
56
|
+
Publisher = [string]$props.Publisher; InstallDate = [string]$props.InstallDate
|
|
57
|
+
EstimatedSizeKb = [long]0 + $props.EstimatedSize; InstallLocation = [string]$props.InstallLocation
|
|
58
|
+
UninstallString = [string]$props.UninstallString; SystemComponent = [int]0 + $props.SystemComponent
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return $rows
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Get-InstalledProgramIndex {
|
|
66
|
+
<# .SYNOPSIS Everything that says "a program owns this name": uninstall entries, program folders, Store
|
|
67
|
+
package names and running processes. RegistryCount drives the fail-closed gate in section 23. #>
|
|
68
|
+
$P = $Script:P
|
|
69
|
+
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
|
|
70
|
+
$rows = @(Get-InstalledProgramRows)
|
|
71
|
+
foreach ($r in $rows) {
|
|
72
|
+
Add-MatchTokens -Set $set -Text $r.DisplayName
|
|
73
|
+
Add-MatchTokens -Set $set -Text $r.Publisher
|
|
74
|
+
if ($r.InstallLocation) { Add-MatchTokens -Set $set -Text (Split-Path -Leaf ($r.InstallLocation.TrimEnd('\'))) }
|
|
75
|
+
}
|
|
76
|
+
foreach ($dir in @($P.PF, $P.PF86, (Join-Path $P.L 'Programs'), (Join-Path $P.L 'Microsoft\WindowsApps'), (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs'))) {
|
|
77
|
+
if (-not $dir -or -not (Test-DirPresent $dir)) { continue }
|
|
78
|
+
foreach ($e in (Get-ChildEntries $dir)) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
|
|
79
|
+
}
|
|
80
|
+
foreach ($e in (Get-ChildEntries (Join-Path $P.L 'Packages'))) { Add-MatchTokens -Set $set -Text (($e.Name -split '_')[0]) }
|
|
81
|
+
try { foreach ($proc in (Get-Process -ErrorAction SilentlyContinue)) { Add-MatchTokens -Set $set -Text $proc.ProcessName } } catch { $null = $_ }
|
|
82
|
+
return [pscustomobject]@{ Tokens = $set; RegistryCount = $rows.Count; Rows = $rows }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function Get-OrphanExclusions {
|
|
86
|
+
<# .SYNOPSIS Folder names section 23 never offers: the fixed list plus, derived at run time, the first
|
|
87
|
+
segment under AppData of every target the tool already declares - so a vendor folder we clean can never
|
|
88
|
+
also be offered for deletion, and the list cannot drift as sections are added. #>
|
|
89
|
+
$P = $Script:P
|
|
90
|
+
$set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
91
|
+
foreach ($n in $Script:WS_ORPHAN_EXCLUDE) { $null = $set.Add($n) }
|
|
92
|
+
$prefixes = @("$($P.A)\", "$($P.L)\")
|
|
93
|
+
foreach ($t in (Get-AllTargets)) {
|
|
94
|
+
$path = [string]$t.Path
|
|
95
|
+
foreach ($pre in $prefixes) {
|
|
96
|
+
if ($path.StartsWith($pre, [StringComparison]::OrdinalIgnoreCase)) {
|
|
97
|
+
$seg = ($path.Substring($pre.Length) -split '\\')[0]
|
|
98
|
+
if ($seg) { $null = $set.Add($seg) }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
# ,$set - a bare `return $set` UNROLLS the HashSet into an Object[], whose .Contains is case-SENSITIVE,
|
|
103
|
+
# so a folder named "slack" would slip past the "Slack" entry. The comma keeps the set a set.
|
|
104
|
+
return , $set
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function Test-OrphanFolderName {
|
|
108
|
+
<# .SYNOPSIS True when NOTHING installed claims this folder name. Deliberately generous in the other
|
|
109
|
+
direction: a token that merely starts with the name (or the reverse) counts as a claim. #>
|
|
110
|
+
param([string] $Name, [pscustomobject] $Index)
|
|
111
|
+
$n = ConvertTo-MatchToken $Name
|
|
112
|
+
if ($n.Length -lt 3) { return $false }
|
|
113
|
+
if ($Index.Tokens.Contains($n)) { return $false }
|
|
114
|
+
foreach ($t in $Index.Tokens) {
|
|
115
|
+
if ($t.Length -lt 4) { continue }
|
|
116
|
+
if ($t.StartsWith($n) -or $n.StartsWith($t)) { return $false }
|
|
117
|
+
}
|
|
118
|
+
return $true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Find-OrphanedAppData {
|
|
122
|
+
<# .SYNOPSIS Top-level AppData folders idle N+ days that no installed program claims. Fails closed.
|
|
123
|
+
-Index is for the self-test: it proves the fail-closed gate without touching the real registry. #>
|
|
124
|
+
param([int] $Days, [pscustomobject] $Index = $null)
|
|
125
|
+
$P = $Script:P
|
|
126
|
+
$index = $Index
|
|
127
|
+
if ($null -eq $index) { $index = Get-InstalledProgramIndex }
|
|
128
|
+
if ($index.RegistryCount -eq 0) {
|
|
129
|
+
Write-Warn 'no uninstall entries could be read: refusing to call anything orphaned (fail closed).'
|
|
130
|
+
return @()
|
|
131
|
+
}
|
|
132
|
+
Write-Note "cross-checked against $($index.RegistryCount) installed-program entries"
|
|
133
|
+
$exclude = Get-OrphanExclusions
|
|
134
|
+
$out = @()
|
|
135
|
+
foreach ($root in @($P.A, $P.L)) {
|
|
136
|
+
if (-not (Test-DirPresent $root)) { continue }
|
|
137
|
+
foreach ($e in (Get-ChildEntries $root)) {
|
|
138
|
+
if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
|
|
139
|
+
if ($exclude.Contains($e.Name)) { continue }
|
|
140
|
+
$full = Remove-LongPrefix $e.FullName
|
|
141
|
+
if (Get-ProtectionReason $full) { continue }
|
|
142
|
+
if (-not (Test-OrphanFolderName -Name $e.Name -Index $index)) { continue }
|
|
143
|
+
$idle = Get-IdleDays $full
|
|
144
|
+
if ($idle -lt $Days) { continue }
|
|
145
|
+
$out += [pscustomobject]@{ Name = $e.Name; Path = $full; Root = (Get-FullPath $root); Idle = $idle; Bytes = [long](Get-DirectoryBytes $full) }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return @($out | Sort-Object Bytes -Descending | Select-Object -First 200)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Get-Targets23 {
|
|
152
|
+
<# .SYNOPSIS Informational rows: the candidates are found at run time, never declared as fixed paths. #>
|
|
153
|
+
$P = $Script:P
|
|
154
|
+
$t = @()
|
|
155
|
+
foreach ($r in @($P.A, $P.L)) {
|
|
156
|
+
if (Test-DirPresent $r) { $t += (New-Target 23 'AppData root scanned for orphans' (Get-FullPath $r) -Kind cmd -Note 'top-level folders only; you pick what goes, and it lands in the Recycle Bin') }
|
|
157
|
+
}
|
|
158
|
+
return $t
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function Invoke-Section23 {
|
|
162
|
+
$ws = $Script:WS
|
|
163
|
+
Write-SectionIntro @(
|
|
164
|
+
'Uninstalling a program usually leaves its AppData folder behind. This lists top-level folders under',
|
|
165
|
+
"Roaming and Local that no installed program, Store package or running process claims and that nothing",
|
|
166
|
+
"has touched for $($ws.Days)+ days. You pick what goes; it lands in the Recycle Bin."
|
|
167
|
+
)
|
|
168
|
+
$rows = @(Find-OrphanedAppData -Days $ws.Days)
|
|
169
|
+
if ($rows.Count -eq 0) { Write-Info 'no orphaned application data found'; return }
|
|
170
|
+
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'FOLDER') 'White'
|
|
171
|
+
$i = 1; $total = [long]0
|
|
172
|
+
foreach ($r in $rows) {
|
|
173
|
+
Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $r.Bytes), $r.Idle, $r.Path) 'Gray'
|
|
174
|
+
Add-JsonCandidate -Section 23 -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle
|
|
175
|
+
$total += $r.Bytes; $i++
|
|
176
|
+
}
|
|
177
|
+
Write-Info ('total: ' + (Format-Bytes $total))
|
|
178
|
+
Write-Note 'a folder here can still hold settings or a licence for a program you plan to reinstall - read the list.'
|
|
179
|
+
$dest = 'the Recycle Bin'
|
|
180
|
+
if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
|
|
181
|
+
Write-Note "selected folders go to $dest"
|
|
182
|
+
$picks = @(Read-MultiSelect -Total $rows.Count -NoAutoYes -Candidates @($rows | ForEach-Object { $_.Path }))
|
|
183
|
+
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
184
|
+
if (-not $ws.DryRun) {
|
|
185
|
+
# Orphaned app data never auto-confirms: --yes does not apply. An explicit --select / --select-file does.
|
|
186
|
+
if (-not (Confirm-Ui -Prompt "Move $($picks.Count) folder(s) to $dest ?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
187
|
+
}
|
|
188
|
+
foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $rows[$k - 1].Path -Within $rows[$k - 1].Root }
|
|
189
|
+
}
|
package/modules/personal.ps1
CHANGED
|
@@ -73,22 +73,24 @@ function Show-FileTable {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function Invoke-PersonalPicker {
|
|
76
|
-
param([object[]] $Rows, [string] $Prompt)
|
|
76
|
+
param([object[]] $Rows, [string] $Prompt, [int] $Section = 0)
|
|
77
77
|
$ws = $Script:WS
|
|
78
78
|
$Rows = @($Rows)
|
|
79
79
|
if ($Rows.Count -eq 0) { Write-Info 'none found'; return }
|
|
80
80
|
Show-FileTable $Rows
|
|
81
81
|
$total = [long]0
|
|
82
|
-
|
|
82
|
+
$i = 1
|
|
83
|
+
foreach ($r in $Rows) { $total += $r.Bytes; Add-JsonCandidate -Section $Section -Index $i -Path $r.Path -Bytes $r.Bytes -IdleDays $r.Idle; $i++ }
|
|
83
84
|
Write-Info ("total: " + (Format-Bytes $total))
|
|
84
85
|
$dest = 'the Recycle Bin'
|
|
85
86
|
if ($ws.Permanent) { $dest = 'PERMANENT deletion (--permanent)' }
|
|
86
87
|
Write-Note "selected files go to $dest"
|
|
87
|
-
$picks = Read-MultiSelect -Total $Rows.Count
|
|
88
|
+
$picks = @(Read-MultiSelect -Total $Rows.Count -NoAutoYes -Candidates @($Rows | ForEach-Object { $_.Path }))
|
|
88
89
|
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
89
90
|
if (-not $ws.DryRun) {
|
|
90
|
-
# Personal files never auto-confirm: --yes does not apply here.
|
|
91
|
-
|
|
91
|
+
# Personal files never auto-confirm: --yes does not apply here. An explicit --select / --select-file
|
|
92
|
+
# choice does, because it is a person naming these exact files in advance.
|
|
93
|
+
if (-not (Confirm-Ui -Prompt "$Prompt ($($picks.Count) file(s))?" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
92
94
|
}
|
|
93
95
|
foreach ($k in $picks) { $null = Send-ToRecycleBin -Path $Rows[$k - 1].Path -Within $Rows[$k - 1].Root }
|
|
94
96
|
}
|
|
@@ -99,7 +101,7 @@ function Invoke-Section18 {
|
|
|
99
101
|
'You pick what goes; it lands in the Recycle Bin. Desktop and cloud-synced folders are never scanned.'
|
|
100
102
|
)
|
|
101
103
|
if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
|
|
102
|
-
Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin'
|
|
104
|
+
Invoke-PersonalPicker -Rows @(Get-PartialDownloads) -Prompt 'Move the selected partial downloads to the Recycle Bin' -Section 18
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
function Invoke-Section19 {
|
|
@@ -109,7 +111,7 @@ function Invoke-Section19 {
|
|
|
109
111
|
'archives you already extracted. You pick what goes; it lands in the Recycle Bin.'
|
|
110
112
|
)
|
|
111
113
|
if (@(Get-PersonalRoots).Count -eq 0) { Write-Info 'no Downloads folder found'; return }
|
|
112
|
-
Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin'
|
|
114
|
+
Invoke-PersonalPicker -Rows @(Get-LargeStaleFiles) -Prompt 'Move the selected files to the Recycle Bin' -Section 19
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
function Show-PersonalScan {
|
package/modules/pkg_managers.ps1
CHANGED
|
@@ -37,6 +37,10 @@ function Get-Targets01 {
|
|
|
37
37
|
(New-Target 1 'electron-builder cache' "$($P.L)\electron-builder\Cache" -Mode prune -Dev $true)
|
|
38
38
|
(New-Target 1 'node-gyp headers cache' "$($P.L)\node-gyp\Cache" -Mode prune -Dev $true)
|
|
39
39
|
(New-Target 1 'Scoop installer cache' "$($P.U)\scoop\cache" -Mode prune -Dev $true)
|
|
40
|
+
# Hugging Face keeps revisions as snapshots\<rev> linking into blobs, so removing a whole snapshot orphans
|
|
41
|
+
# blobs: this prunes by the idle gate instead of keeping "the newest per model". AI agent state under
|
|
42
|
+
# ~\.cache (claude, codex, gemini, copilot) stays protected by lib/safety.ps1.
|
|
43
|
+
(New-Target 1 'Hugging Face model cache' "$($P.U)\.cache\huggingface\hub" -Mode prune -Dev $true -Note 'models are re-downloaded on next use; the idle gate keeps recent ones')
|
|
40
44
|
)
|
|
41
45
|
}
|
|
42
46
|
|
package/modules/projects.ps1
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
|
|
2
2
|
|
|
3
|
-
$Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular')
|
|
3
|
+
$Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
|
|
4
|
+
# Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
|
|
5
|
+
# A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
|
|
6
|
+
# .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
|
|
7
|
+
$Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
|
|
4
8
|
$Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
|
|
5
9
|
$Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
|
|
6
10
|
|
|
@@ -37,6 +41,15 @@ function Get-Targets17 {
|
|
|
37
41
|
return $t
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
function Test-MarkedArtefactDir {
|
|
45
|
+
<# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
|
|
46
|
+
param([string] $Dir, [string] $Name)
|
|
47
|
+
if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
|
|
48
|
+
$names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
|
|
49
|
+
foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
|
|
50
|
+
return $false
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
function Test-ProjectMarker {
|
|
41
54
|
param([string] $Dir, [string] $ArtefactName)
|
|
42
55
|
$entries = Get-ChildEntries $Dir
|
|
@@ -59,7 +72,7 @@ function Get-ProjectActivityDays {
|
|
|
59
72
|
$seen++
|
|
60
73
|
if (Test-ReparsePoint $e) { continue }
|
|
61
74
|
if ($e -is [IO.DirectoryInfo]) {
|
|
62
|
-
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name) { continue }
|
|
75
|
+
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
|
|
63
76
|
$stack.Push((Remove-LongPrefix $e.FullName)); continue
|
|
64
77
|
}
|
|
65
78
|
$t = Get-NewestTimestampUtc $e
|
|
@@ -88,7 +101,7 @@ function Find-StaleArtefacts {
|
|
|
88
101
|
$skip = $false
|
|
89
102
|
foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
|
|
90
103
|
if ($skip) { continue }
|
|
91
|
-
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj')) {
|
|
104
|
+
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
|
|
92
105
|
if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
|
|
93
106
|
if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
|
|
94
107
|
$age = $projectAge[$dir]
|
|
@@ -116,24 +129,31 @@ function Invoke-Section17 {
|
|
|
116
129
|
$found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
|
|
117
130
|
if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
|
|
118
131
|
$reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
|
|
132
|
+
# One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
|
|
133
|
+
$offered = @($found | Select-Object -First 200)
|
|
134
|
+
if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
|
|
119
135
|
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
|
|
120
136
|
$i = 1; $total = [long]0
|
|
121
|
-
foreach ($f in $
|
|
137
|
+
foreach ($f in $offered) {
|
|
122
138
|
Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
|
|
123
139
|
$reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
|
|
140
|
+
Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
|
|
124
141
|
$total += $f.Bytes; $i++
|
|
125
|
-
if ($i -gt 200) { Write-Warn 'list truncated to 200 entries'; break }
|
|
126
142
|
}
|
|
127
143
|
Write-Info ("total: " + (Format-Bytes $total))
|
|
128
144
|
if (-not $ws.NoReport) {
|
|
129
145
|
$out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
|
|
130
146
|
try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
|
|
131
147
|
}
|
|
132
|
-
$picks = Read-MultiSelect -Total
|
|
148
|
+
$picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
|
|
133
149
|
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
134
|
-
if (-not
|
|
150
|
+
if (-not $ws.DryRun) {
|
|
151
|
+
# Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
|
|
152
|
+
# An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
|
|
153
|
+
if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
154
|
+
}
|
|
135
155
|
foreach ($k in $picks) {
|
|
136
|
-
$f = $
|
|
156
|
+
$f = $offered[$k - 1]
|
|
137
157
|
$r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
|
|
138
158
|
if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
|
|
139
159
|
}
|
|
@@ -18,6 +18,9 @@ function Show-Version {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function Show-SectionList {
|
|
21
|
+
# --list --json prints the catalogue and nothing else: the human table below uses Write-Host, which would
|
|
22
|
+
# break the one-line-on-stdout promise.
|
|
23
|
+
if ($Script:WS.JsonMode) { [Console]::Out.WriteLine(((Get-CatalogueJson) | ConvertTo-Json -Depth 6 -Compress)); return }
|
|
21
24
|
Write-Host ''
|
|
22
25
|
Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f '#', 'SECTION', 'TIER', 'ADMIN', 'BATCH')
|
|
23
26
|
foreach ($s in $Script:WS_SECTIONS) {
|
|
@@ -241,6 +244,12 @@ function Invoke-SelfTest {
|
|
|
241
244
|
try { $probe = Join-Path $d ('.write-test-' + [guid]::NewGuid().ToString('N')); Set-Content -LiteralPath $probe -Value 'x'; Remove-Item -LiteralPath $probe -Force; Write-Ok "$d writable" } catch { Write-Err "$d not writable"; $fails++ }
|
|
242
245
|
}
|
|
243
246
|
|
|
247
|
+
if (Get-Command Invoke-SelfTestExtra -ErrorAction SilentlyContinue) {
|
|
248
|
+
$extra = Invoke-SelfTestExtra
|
|
249
|
+
$checks += [int]$extra.Checks
|
|
250
|
+
$fails += [int]$extra.Fails
|
|
251
|
+
}
|
|
252
|
+
|
|
244
253
|
Write-Separator
|
|
245
254
|
if ($fails -eq 0) { Write-Ok "all $checks checks passed - $Script:WS_NAME is ready"; return $Script:WS_EXIT_OK }
|
|
246
255
|
Write-Err "$fails of $checks checks FAILED"
|
|
@@ -317,11 +326,25 @@ function Get-LaunchCommand {
|
|
|
317
326
|
return [pscustomobject]@{ Exe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; Args = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', "`"$($ws.ScriptPath)`"") }
|
|
318
327
|
}
|
|
319
328
|
|
|
329
|
+
function Test-NpxInstallerRefusal {
|
|
330
|
+
<# .SYNOPSIS Under npx the package lives in a cache npm evicts; a task or alias pointing there would break later. #>
|
|
331
|
+
param([string] $Flag)
|
|
332
|
+
if (-not $Script:WS.Npx) { return $false }
|
|
333
|
+
Write-Err "$Flag is refused under npx: it would point at the npx cache, which npm evicts."
|
|
334
|
+
Write-Plain ' Install once, then register from the global command:'
|
|
335
|
+
Write-Plain ' npm install -g windowsweep'
|
|
336
|
+
Write-Plain " windowsweep $Flag"
|
|
337
|
+
$Script:WS.ExitCode = $Script:WS_EXIT_REFUSED
|
|
338
|
+
return $true
|
|
339
|
+
}
|
|
340
|
+
|
|
320
341
|
function Install-WeeklyTask {
|
|
321
342
|
Write-Box 'Install weekly Scheduled Task' "$Script:WS_TASK_NAME - Sundays 03:00, safe batch, no prompts"
|
|
343
|
+
if (Test-NpxInstallerRefusal '--install-task') { return }
|
|
322
344
|
if (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue) { Write-Info 'task already exists'; return }
|
|
323
345
|
$lc = Get-LaunchCommand
|
|
324
|
-
|
|
346
|
+
# --notify is part of the task action: an unattended Sunday run that says nothing is a run nobody sees.
|
|
347
|
+
$argLine = (@($lc.Args) + @('--all', '--yes', '--quiet', '--no-color', '--notify')) -join ' '
|
|
325
348
|
Write-Info "action: $($lc.Exe) $argLine"
|
|
326
349
|
if ($Script:WS.DryRun) { Write-DryRun 'would register the task'; return }
|
|
327
350
|
if (-not (Confirm-Ui -Prompt 'Register this task for your user account?' -Default 'y')) { Write-Info 'skipped'; return }
|
|
@@ -347,6 +370,7 @@ $Script:WS_ALIAS_MARK = '# windowsweep alias'
|
|
|
347
370
|
|
|
348
371
|
function Install-ProfileAlias {
|
|
349
372
|
Write-Box 'Install profile alias' "Adds a 'cleanup' function to your PowerShell profile"
|
|
373
|
+
if (Test-NpxInstallerRefusal '--install-alias') { return }
|
|
350
374
|
$profilePath = $PROFILE.CurrentUserAllHosts
|
|
351
375
|
$lc = Get-LaunchCommand
|
|
352
376
|
$line = "function cleanup { & `"$($lc.Exe)`" $($lc.Args -join ' ') @args }"
|
|
@@ -388,7 +412,7 @@ function Remove-ToolData {
|
|
|
388
412
|
$bytes = Get-DirectoryBytes $ws.Home
|
|
389
413
|
Write-Info ("$($ws.Home) holds " + (Format-Bytes $bytes) + ' of logs, reports, bundles and config')
|
|
390
414
|
if ($ws.DryRun) { Write-DryRun "would remove $($ws.Home)"; return }
|
|
391
|
-
if (-not (Confirm-Ui -Prompt 'Delete it all (logs, reports, your developer answer)?' -Default 'n')) { Write-Info 'skipped'; return }
|
|
415
|
+
if (-not (Confirm-Ui -Prompt 'Delete it all (logs, reports, your developer answer)?' -Default 'n' -NoAutoYes)) { Write-Info 'skipped'; return }
|
|
392
416
|
$ws.LogFile = $null
|
|
393
417
|
try { [IO.Directory]::Delete((Get-LongPath $ws.Home), $true); Write-Ok 'removed' } catch { Write-Err "could not remove: $($_.Exception.Message)" }
|
|
394
418
|
}
|
package/modules/runner.ps1
CHANGED
|
@@ -42,6 +42,29 @@ function Get-RequestedSections {
|
|
|
42
42
|
return @($ids | Sort-Object -Unique)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function Get-MachineProgressLine {
|
|
46
|
+
<# .SYNOPSIS The progress-line format a --json caller parses. Kept pure so the self-test can round-trip it. #>
|
|
47
|
+
param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
|
|
48
|
+
$line = "##$Script:WS_NAME section=$Section event=$Stage"
|
|
49
|
+
if ($Stage -eq 'end') { $line += " status=$Status freed_bytes=$Freed" }
|
|
50
|
+
return $line
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Write-MachineProgress {
|
|
54
|
+
<# .SYNOPSIS One machine-readable progress line on stderr, --json mode only, so a GUI need not parse the log.
|
|
55
|
+
Never on stdout: --json promises exactly one stdout line. #>
|
|
56
|
+
param([int] $Section, [string] $Stage, [string] $Status = '', [long] $Freed = 0)
|
|
57
|
+
if (-not $Script:WS.JsonMode) { return }
|
|
58
|
+
[Console]::Error.WriteLine((Get-MachineProgressLine -Section $Section -Stage $Stage -Status $Status -Freed $Freed))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Add-JsonCandidate {
|
|
62
|
+
<# .SYNOPSIS Record one item an interactive section offered, so --json callers can present the same list. #>
|
|
63
|
+
param([int] $Section, [int] $Index, [string] $Path, [long] $Bytes, [int] $IdleDays, [string] $Project = '')
|
|
64
|
+
if (-not $Script:WS.JsonMode) { return }
|
|
65
|
+
$Script:WS.Candidates += [ordered]@{ section = $Section; index = $Index; path = $Path; bytes = $Bytes; idle_days = $IdleDays; project = $Project }
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
function Invoke-SectionById {
|
|
46
69
|
<# .SYNOPSIS Run one section under the batch, admin and developer policies; records the report step. #>
|
|
47
70
|
param([int] $Id)
|
|
@@ -50,18 +73,24 @@ function Invoke-SectionById {
|
|
|
50
73
|
if (-not $sec) { Write-Warn "no section $Id"; return }
|
|
51
74
|
$title = "[{0:00}] {1}" -f $sec.Id, $sec.Title
|
|
52
75
|
Write-Box $title
|
|
76
|
+
Write-MachineProgress -Section $Id -Stage 'start'
|
|
53
77
|
# Batch policy: deep and interactive-only sections never run unattended without an explicit opt-in.
|
|
54
78
|
if ($ws.BatchMode -and -not $ws.DryRun) {
|
|
55
|
-
if ($sec.Batch -eq 'interactive') {
|
|
56
|
-
Write-Warn "section $Id is interactive-only: it needs a person at the keyboard
|
|
79
|
+
if ($sec.Batch -eq 'interactive' -and -not $ws.SelectActive) {
|
|
80
|
+
Write-Warn "section $Id is interactive-only: it needs a person at the keyboard, or a selection passed with --select / --select-file."
|
|
57
81
|
$ws.Refusals += "section $Id (interactive-only)"
|
|
58
82
|
Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'interactive-only section in batch mode'
|
|
83
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
|
|
59
84
|
return
|
|
60
85
|
}
|
|
86
|
+
if ($sec.Batch -eq 'interactive') {
|
|
87
|
+
Write-Note "section $Id runs unattended because a selection was supplied (--select / --select-file): a person chose these."
|
|
88
|
+
}
|
|
61
89
|
if ($sec.Batch -eq 'deep' -and -not $ws.Deep) {
|
|
62
90
|
Write-Warn "section $Id is deep (irreversible or system-changing): refused in batch mode without --i-understand-deep."
|
|
63
91
|
$ws.Refusals += "section $Id (deep, add --i-understand-deep)"
|
|
64
92
|
Add-ReportStep -Section $Id -Title $sec.Title -Status 'refused' -Note 'deep section without --i-understand-deep'
|
|
93
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status 'refused'
|
|
65
94
|
return
|
|
66
95
|
}
|
|
67
96
|
}
|
|
@@ -70,11 +99,13 @@ function Invoke-SectionById {
|
|
|
70
99
|
Write-Note "run it elevated: windowsweep --only $Id --yes --elevate"
|
|
71
100
|
$ws.Hints += "Admin section $Id skipped: windowsweep --only $Id --yes --elevate"
|
|
72
101
|
Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'needs Administrator'
|
|
102
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
|
|
73
103
|
return
|
|
74
104
|
}
|
|
75
105
|
if ($sec.Dev -and $ws.Developer -eq $false -and $Id -in @(4, 17, 20)) {
|
|
76
106
|
Write-Info "developer mode is off - section $Id (developer-only data) skipped."
|
|
77
107
|
Add-ReportStep -Section $Id -Title $sec.Title -Status 'skipped' -Note 'developer mode off'
|
|
108
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status 'skipped'
|
|
78
109
|
return
|
|
79
110
|
}
|
|
80
111
|
$ws.SectionFreed = [long]0
|
|
@@ -86,7 +117,7 @@ function Invoke-SectionById {
|
|
|
86
117
|
} catch {
|
|
87
118
|
$status = 'failed'
|
|
88
119
|
Write-Err "section $Id failed: $($_.Exception.Message)"
|
|
89
|
-
Write-
|
|
120
|
+
Write-LogLine ("section $Id error: " + $_.ScriptStackTrace)
|
|
90
121
|
$ws.ExitCode = $Script:WS_EXIT_FAIL
|
|
91
122
|
}
|
|
92
123
|
$after = Get-SystemDriveFree
|
|
@@ -101,6 +132,7 @@ function Invoke-SectionById {
|
|
|
101
132
|
Write-Note 'no measurable change in this section'
|
|
102
133
|
}
|
|
103
134
|
Add-ReportStep -Section $Id -Title $sec.Title -Status $status -Freed $freed
|
|
135
|
+
Write-MachineProgress -Section $Id -Stage 'end' -Status $status -Freed $freed
|
|
104
136
|
}
|
|
105
137
|
|
|
106
138
|
function Invoke-BatchMode {
|
|
@@ -173,18 +205,35 @@ function Show-SessionSummary {
|
|
|
173
205
|
}
|
|
174
206
|
Write-UiLine '' 'Gray'
|
|
175
207
|
Write-Note "$Script:WS_NAME v$(Get-ToolVersion) by $Script:WS_AUTHOR - $Script:WS_REPO"
|
|
208
|
+
Send-RunNotification
|
|
176
209
|
if ($ws.JsonMode) { Write-JsonSummary }
|
|
177
210
|
}
|
|
178
211
|
|
|
179
|
-
function
|
|
180
|
-
<# .SYNOPSIS --json
|
|
212
|
+
function Get-JsonSummary {
|
|
213
|
+
<# .SYNOPSIS The --json document (ordered), kept apart from the printer so the self-test can parse it. #>
|
|
181
214
|
$ws = $Script:WS
|
|
182
|
-
|
|
215
|
+
return [ordered]@{
|
|
183
216
|
tool = $Script:WS_NAME; version = (Get-ToolVersion); mode = $ws.Mode; dry_run = [bool]$ws.DryRun
|
|
184
217
|
elevated = [bool]$ws.IsAdmin; developer = $ws.Developer
|
|
185
218
|
freed_bytes = [long]$ws.TotalFreed; estimated_bytes = [long]$ws.TotalEstimated
|
|
186
219
|
sections = @($ws.Report.steps | ForEach-Object { [ordered]@{ section = $_.section; status = $_.status; freed_bytes = $_.freed_bytes } })
|
|
220
|
+
# candidates and targets are ALWAYS present, empty when nothing was collected: a caller can rely on the shape.
|
|
221
|
+
candidates = @($ws.Candidates); targets = @($ws.ScanTargets)
|
|
187
222
|
refusals = @($ws.Refusals); log_file = $ws.LogFile; report_file = $ws.ReportFile
|
|
188
223
|
}
|
|
189
|
-
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function Get-CatalogueJson {
|
|
227
|
+
<# .SYNOPSIS --list --json: the section catalogue, so a front end reads it instead of hard-coding it. #>
|
|
228
|
+
return [ordered]@{
|
|
229
|
+
tool = $Script:WS_NAME; version = (Get-ToolVersion)
|
|
230
|
+
sections = @($Script:WS_SECTIONS | ForEach-Object { [ordered]@{ id = $_.Id; key = $_.Key; title = $_.Title; tier = $_.Tier; admin = [bool]$_.Admin; batch = $_.Batch; dev = [bool]$_.Dev } })
|
|
231
|
+
safe_batch = @($Script:WS_SAFE_BATCH); safe_batch_admin = @($Script:WS_SAFE_BATCH_ADMIN)
|
|
232
|
+
profiles = ([ordered]@{} + $Script:WS_PROFILES); walkthrough = @($Script:WS_WALKTHROUGH); walkthrough_admin = @($Script:WS_WALKTHROUGH_ADMIN)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function Write-JsonSummary {
|
|
237
|
+
<# .SYNOPSIS --json: the only line written to stdout. #>
|
|
238
|
+
[Console]::Out.WriteLine(((Get-JsonSummary) | ConvertTo-Json -Depth 5 -Compress))
|
|
190
239
|
}
|