windowsweep 1.0.1 → 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 +68 -0
- package/README.md +22 -11
- package/VERSION +1 -1
- package/lib/actions.ps1 +8 -5
- package/lib/constants.ps1 +7 -3
- package/lib/scan.ps1 +10 -4
- package/lib/ui.ps1 +47 -7
- 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 +25 -8
- package/modules/release_helpers.ps1 +5 -1
- package/modules/runner.ps1 +55 -6
- package/modules/self_test_extra.ps1 +276 -2
- package/modules/startup_audit.ps1 +126 -0
- package/modules/windows_user.ps1 +6 -0
- package/package.json +3 -2
- package/windowsweep.ps1 +21 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# startup_audit.ps1 - section 25: everything that starts with the user or the machine. Report only.
|
|
2
|
+
#
|
|
3
|
+
# The tool shows; the user decides in Task Manager. That non-goal is deliberate: disabling a startup item is
|
|
4
|
+
# a system configuration change, not cleanup, and a wrong one is invisible until the next boot.
|
|
5
|
+
|
|
6
|
+
function Get-StartupApprovedState {
|
|
7
|
+
<# .SYNOPSIS enabled | disabled | unknown from an Explorer StartupApproved value.
|
|
8
|
+
The value is 12 bytes: a DWORD of flags then a FILETIME. Explorer stamps that FILETIME when the item is
|
|
9
|
+
DISABLED and zeroes it when the item is enabled, so a non-zero tail is the reliable signal.
|
|
10
|
+
Measured on Windows 10 19045 (2026-09-04): five items carried a real FILETIME under a leading 0x01, and
|
|
11
|
+
two enabled ones (0x01 and 0x04) carried an all-zero tail - the widely quoted "byte 0 is 02/03" table
|
|
12
|
+
does not hold there, so the tail is read first and 03 / 07 are kept only as a fallback marker.
|
|
13
|
+
A value shorter than 12 bytes is reported as unknown; it is never guessed. #>
|
|
14
|
+
param([byte[]] $Value)
|
|
15
|
+
if ($null -eq $Value -or $Value.Length -lt 12) { return 'unknown' }
|
|
16
|
+
for ($i = 4; $i -lt 12; $i++) { if ($Value[$i] -ne 0) { return 'disabled' } }
|
|
17
|
+
if ($Value[0] -eq 3 -or $Value[0] -eq 7) { return 'disabled' }
|
|
18
|
+
return 'enabled'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Get-StartupApprovedMap {
|
|
22
|
+
<# .SYNOPSIS name -> enabled|disabled|unknown, from the StartupApproved keys Explorer maintains. #>
|
|
23
|
+
$map = @{}
|
|
24
|
+
$keys = @(
|
|
25
|
+
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
|
|
26
|
+
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32',
|
|
27
|
+
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder',
|
|
28
|
+
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
|
|
29
|
+
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder'
|
|
30
|
+
)
|
|
31
|
+
foreach ($k in $keys) {
|
|
32
|
+
$props = $null
|
|
33
|
+
try { $props = Get-ItemProperty -LiteralPath $k -ErrorAction Stop } catch { continue }
|
|
34
|
+
foreach ($prop in $props.PSObject.Properties) {
|
|
35
|
+
if ($prop.Name.StartsWith('PS')) { continue }
|
|
36
|
+
if (-not ($prop.Value -is [byte[]])) { continue }
|
|
37
|
+
$map[$prop.Name] = Get-StartupApprovedState -Value $prop.Value
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return $map
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Get-StartupItems {
|
|
44
|
+
<# .SYNOPSIS Every startup entry the user can see, from all five places Windows keeps them. #>
|
|
45
|
+
$P = $Script:P
|
|
46
|
+
$approved = Get-StartupApprovedMap
|
|
47
|
+
$items = @()
|
|
48
|
+
$runKeys = @(
|
|
49
|
+
@('HKCU Run', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'),
|
|
50
|
+
@('HKCU RunOnce', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'),
|
|
51
|
+
@('HKLM Run', 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'),
|
|
52
|
+
@('HKLM RunOnce', 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'),
|
|
53
|
+
@('HKLM Run (32)', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'),
|
|
54
|
+
@('HKLM RunOnce (32)', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce')
|
|
55
|
+
)
|
|
56
|
+
foreach ($rk in $runKeys) {
|
|
57
|
+
# NOT $p: PowerShell variable names are case-insensitive, so $p would silently null the $P roots table.
|
|
58
|
+
$props = $null
|
|
59
|
+
try { $props = Get-ItemProperty -LiteralPath $rk[1] -ErrorAction Stop } catch { continue }
|
|
60
|
+
foreach ($prop in $props.PSObject.Properties) {
|
|
61
|
+
if ($prop.Name.StartsWith('PS')) { continue }
|
|
62
|
+
$state = 'enabled'
|
|
63
|
+
if ($approved.ContainsKey($prop.Name)) { $state = $approved[$prop.Name] }
|
|
64
|
+
$items += [pscustomobject]@{ Source = $rk[0]; Name = $prop.Name; State = $state; Command = [string]$prop.Value }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
foreach ($folder in @(@('Startup (user)', (Join-Path $P.A 'Microsoft\Windows\Start Menu\Programs\Startup')), @('Startup (all users)', (Join-Path $P.PD 'Microsoft\Windows\Start Menu\Programs\Startup')))) {
|
|
68
|
+
if (-not (Test-DirPresent $folder[1])) { continue }
|
|
69
|
+
foreach ($e in (Get-ChildEntries $folder[1])) {
|
|
70
|
+
if ($e -is [IO.DirectoryInfo] -or $e.Name -eq 'desktop.ini') { continue }
|
|
71
|
+
$state = 'enabled'
|
|
72
|
+
if ($approved.ContainsKey($e.Name)) { $state = $approved[$e.Name] }
|
|
73
|
+
$items += [pscustomobject]@{ Source = $folder[0]; Name = $e.Name; State = $state; Command = (Remove-LongPrefix $e.FullName) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
foreach ($t in (Get-ScheduledTask -ErrorAction Stop)) {
|
|
78
|
+
if (-not (@($t.Triggers) | Where-Object { $_.CimClass.CimClassName -eq 'MSFT_TaskLogonTrigger' })) { continue }
|
|
79
|
+
$state = 'enabled'
|
|
80
|
+
if ($t.State -eq 'Disabled') { $state = 'disabled' }
|
|
81
|
+
$cmd = ''
|
|
82
|
+
foreach ($a in @($t.Actions)) { if ($a.Execute) { $cmd = [string]$a.Execute } }
|
|
83
|
+
$items += [pscustomobject]@{ Source = 'Scheduled task (logon)'; Name = $t.TaskName; State = $state; Command = $cmd }
|
|
84
|
+
}
|
|
85
|
+
} catch { $null = $_ }
|
|
86
|
+
try {
|
|
87
|
+
foreach ($c in (Get-CimInstance Win32_StartupCommand -ErrorAction Stop)) {
|
|
88
|
+
if (@($items | Where-Object { $_.Name -eq $c.Name }).Count -gt 0) { continue }
|
|
89
|
+
$state = 'enabled'
|
|
90
|
+
if ($approved.ContainsKey([string]$c.Name)) { $state = $approved[[string]$c.Name] }
|
|
91
|
+
$items += [pscustomobject]@{ Source = "WMI ($($c.Location))"; Name = [string]$c.Name; State = $state; Command = [string]$c.Command }
|
|
92
|
+
}
|
|
93
|
+
} catch { $null = $_ }
|
|
94
|
+
return @($items | Sort-Object Source, Name)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Get-Targets25 {
|
|
98
|
+
<# .SYNOPSIS Informational only. Section 25 changes nothing: it never disables or removes a startup item. #>
|
|
99
|
+
return @((New-Target 25 'Startup items (registry, folders, tasks)' 'Run keys + Startup folders + logon tasks' -Kind cmd -Note 'report only; change them yourself in Task Manager > Startup'))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function Invoke-Section25 {
|
|
103
|
+
$ws = $Script:WS
|
|
104
|
+
Write-SectionIntro @(
|
|
105
|
+
'Everything that launches when you sign in or the machine boots: Run and RunOnce keys, both Startup',
|
|
106
|
+
'folders, scheduled tasks with a logon trigger, and what Explorer records as enabled or disabled.',
|
|
107
|
+
'This section only shows them - change them in Task Manager > Startup, where you can undo it.'
|
|
108
|
+
)
|
|
109
|
+
$items = @(Get-StartupItems)
|
|
110
|
+
if ($items.Count -eq 0) { Write-Info 'no startup items found'; return }
|
|
111
|
+
Write-UiLine (" {0,-22} {1,-30} {2,-9} {3}" -f 'SOURCE', 'NAME', 'STATE', 'COMMAND') 'White'
|
|
112
|
+
$lines = @("startup items - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
|
|
113
|
+
foreach ($it in $items) {
|
|
114
|
+
$color = 'Gray'
|
|
115
|
+
if ($it.State -eq 'disabled') { $color = 'DarkGray' }
|
|
116
|
+
$line = " {0,-22} {1,-30} {2,-9} {3}" -f $it.Source, $it.Name, $it.State, $it.Command
|
|
117
|
+
Write-UiLine $line $color
|
|
118
|
+
$lines += $line.Trim()
|
|
119
|
+
}
|
|
120
|
+
$on = @($items | Where-Object { $_.State -eq 'enabled' }).Count
|
|
121
|
+
Write-Info "$($items.Count) startup item(s), $on enabled"
|
|
122
|
+
if (-not $ws.NoReport) {
|
|
123
|
+
$out = Join-Path $ws.ReportsDir ('startup-items-' + $ws.Stamp + '.txt')
|
|
124
|
+
try { [IO.File]::WriteAllLines($out, $lines); Write-Note "list saved: $out" } catch { $null = $_ }
|
|
125
|
+
}
|
|
126
|
+
}
|
package/modules/windows_user.ps1
CHANGED
|
@@ -31,6 +31,7 @@ function Get-Targets09 {
|
|
|
31
31
|
(New-Target 9 'Certificate URL cache' "$($P.LL)\Microsoft\CryptnetUrlCache" -Mode clear)
|
|
32
32
|
(New-Target 9 'Diagnostics results' "$($P.L)\Diagnostics" -Mode clear-old -Days 30)
|
|
33
33
|
(New-Target 9 'DNS resolver cache' 'ipconfig /flushdns' -Kind cmd)
|
|
34
|
+
(New-Target 9 'Microsoft Store download cache' 'wsreset.exe' -Kind cmd -Note 'printed as a next step, never run for you: wsreset has no silent mode and always opens the Store')
|
|
34
35
|
)
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -46,6 +47,11 @@ function Invoke-Section09 {
|
|
|
46
47
|
$null = Invoke-TargetList $targets
|
|
47
48
|
$r = Invoke-External -FilePath 'ipconfig.exe' -ArgumentList @('/flushdns') -Destructive -Quiet -Label 'ipconfig /flushdns'
|
|
48
49
|
if ($r.Ran -and $r.ExitCode -eq 0) { Write-Ok 'DNS resolver cache flushed' }
|
|
50
|
+
# wsreset is the only lever for the Store download cache, and it has no silent switch: every build opens
|
|
51
|
+
# the Store window when it finishes. A cleanup run must not pop a window, so it is offered, never executed.
|
|
52
|
+
if (Test-PathPresent (Join-Path $Script:P.SR 'System32\wsreset.exe')) {
|
|
53
|
+
$Script:WS.Hints += 'Microsoft Store cache: run wsreset.exe yourself when you want it cleared (it opens the Store when it finishes)'
|
|
54
|
+
}
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
# ---------------------------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windowsweep",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Safe-by-default Windows cleanup CLI - developer-aware, dry-run first, zero install via npx.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"windowsweep": "bin/windowsweep.js"
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"LICENSE",
|
|
25
25
|
"CHANGELOG.md",
|
|
26
26
|
"SECURITY.md",
|
|
27
|
-
"VERSION"
|
|
27
|
+
"VERSION",
|
|
28
|
+
"AI-INTEGRATION-GUIDE.md"
|
|
28
29
|
],
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=14"
|
package/windowsweep.ps1
CHANGED
|
@@ -20,7 +20,11 @@ $Script:WS = @{
|
|
|
20
20
|
ScanRoots = @(); ExcludePaths = @(); Hiberfil = ''; ResetBase = $false; Permanent = $false
|
|
21
21
|
OnlyList = ''; Profile = ''; Exclude = ''; ExportFmt = 'both'; ExportId = 'latest'; PruneDays = 90
|
|
22
22
|
LogsDir = ''; ReportsDir = ''; NoReport = $false; CleanupLogs = $false
|
|
23
|
-
JsonMode = $false; Quiet = $false; NoColor = $false; Ascii = $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 = @()
|
|
24
28
|
TotalFreed = [long]0; TotalEstimated = [long]0; SectionFreed = [long]0
|
|
25
29
|
Refusals = @(); Hints = @(); ExitCode = 0; Finished = $false; AllowOwnData = $false
|
|
26
30
|
}
|
|
@@ -79,6 +83,11 @@ OPTIONS
|
|
|
79
83
|
--large-file-mb N Minimum size for section 19 (default 100)
|
|
80
84
|
--hiberfil off|reduced|keep What section 15 does --reset-base DISM /ResetBase in 14
|
|
81
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
|
|
82
91
|
--logs-dir P / --reports-dir P / --no-report / --cleanup-logs
|
|
83
92
|
--json One-line JSON summary on stdout (everything else on stderr)
|
|
84
93
|
-q, --quiet Less chatter --no-color --ascii (plain glyphs)
|
|
@@ -167,6 +176,15 @@ function Read-Arguments {
|
|
|
167
176
|
'--reports-dir' { $v = $inline; if ($null -eq $v) { $v = Get-NextArg $argv ([ref]$i) $a }; $ws.ReportsDir = $v }
|
|
168
177
|
'--no-report' { $ws.NoReport = $true }
|
|
169
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
|
+
}
|
|
170
188
|
'--json' { $ws.JsonMode = $true; $ws.Quiet = $true }
|
|
171
189
|
{ $_ -in '-q', '--quiet' } { $ws.Quiet = $true }
|
|
172
190
|
'--no-color' { $ws.NoColor = $true }
|
|
@@ -179,6 +197,8 @@ function Read-Arguments {
|
|
|
179
197
|
if ($ws.Profile -and -not $Script:WS_PROFILES.ContainsKey($ws.Profile)) {
|
|
180
198
|
throw "unknown profile '$($ws.Profile)' (known: $($Script:WS_PROFILES.Keys -join ', '))"
|
|
181
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)
|
|
182
202
|
}
|
|
183
203
|
|
|
184
204
|
function Initialize-Paths {
|