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,236 @@
|
|
|
1
|
+
# globals_audit.ps1 - section 22: globally installed packages, read-only.
|
|
2
|
+
# Lists what npm, pnpm, yarn, bun and deno installed globally, flags the ones nothing has referenced for
|
|
3
|
+
# N+ days, and prints the exact uninstall command. It NEVER removes anything, in any mode: several of these
|
|
4
|
+
# roots (nvm4w, AppData\npm, pnpm\global, .bun, .deno\bin) are protected subtrees by design.
|
|
5
|
+
|
|
6
|
+
# Never proposed as a candidate: the tool itself and the package managers everything else depends on.
|
|
7
|
+
$Script:WS_GLOBAL_KEEP = @('npm', 'corepack', 'pnpm', 'yarn', 'windowsweep')
|
|
8
|
+
|
|
9
|
+
function Get-GlobalPackageRoots {
|
|
10
|
+
<# .SYNOPSIS Existing global install roots, one row per manager. Style: node_modules (packages) | bin (shims). #>
|
|
11
|
+
$P = $Script:P
|
|
12
|
+
$rows = @()
|
|
13
|
+
$npmRoot = ''
|
|
14
|
+
if (Test-CommandPresent 'npm') {
|
|
15
|
+
try {
|
|
16
|
+
$out = & npm root -g 2>$null
|
|
17
|
+
$line = ($out | Select-Object -Last 1)
|
|
18
|
+
if ($line) { $npmRoot = $line.Trim() }
|
|
19
|
+
} catch { $null = $_ }
|
|
20
|
+
}
|
|
21
|
+
if (-not $npmRoot) { $npmRoot = Join-Path $P.A 'npm\node_modules' }
|
|
22
|
+
$fixed = @(
|
|
23
|
+
@('npm', $npmRoot, 'node_modules'),
|
|
24
|
+
@('yarn', "$($P.L)\Yarn\Data\global\node_modules", 'node_modules'),
|
|
25
|
+
@('bun', "$($P.U)\.bun\install\global\node_modules", 'node_modules'),
|
|
26
|
+
@('deno', "$($P.U)\.deno\bin", 'bin')
|
|
27
|
+
)
|
|
28
|
+
foreach ($f in $fixed) {
|
|
29
|
+
if (Test-DirPresent $f[1]) { $rows += [pscustomobject]@{ Manager = $f[0]; Root = (Get-FullPath $f[1]); Style = $f[2] } }
|
|
30
|
+
}
|
|
31
|
+
# Version-managed and store-style roots are globs: one root per installed Node version / pnpm version.
|
|
32
|
+
$globs = @()
|
|
33
|
+
$globs += @(@('npm', "$($P.A)\nvm\v*\node_modules"))
|
|
34
|
+
if ($env:NVM_HOME) { $globs += @(@('npm', (Join-Path $env:NVM_HOME 'v*\node_modules'))) }
|
|
35
|
+
$globs += @(@('pnpm', "$($P.L)\pnpm\global\*\node_modules"))
|
|
36
|
+
foreach ($g in $globs) {
|
|
37
|
+
$hits = @()
|
|
38
|
+
try { $hits = @(Get-Item -Path $g[1] -Force -ErrorAction SilentlyContinue | Where-Object { $_ -is [IO.DirectoryInfo] } | ForEach-Object { $_.FullName }) } catch { $hits = @() }
|
|
39
|
+
foreach ($h in $hits) { $rows += [pscustomobject]@{ Manager = $g[0]; Root = (Get-FullPath $h); Style = 'node_modules' } }
|
|
40
|
+
}
|
|
41
|
+
$seen = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
42
|
+
return @($rows | Where-Object { $seen.Add($_.Root) })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Get-PackageBinNames {
|
|
46
|
+
<# .SYNOPSIS Command names a package.json 'bin' field installs: a string means one bin named after the package. #>
|
|
47
|
+
param([object] $Manifest, [string] $Name)
|
|
48
|
+
$out = @()
|
|
49
|
+
if ($null -eq $Manifest) { return $out }
|
|
50
|
+
$bin = $Manifest.bin
|
|
51
|
+
if ($null -eq $bin) { return $out }
|
|
52
|
+
if ($bin -is [string]) { return @(($Name -split '/')[-1]) }
|
|
53
|
+
foreach ($p in $bin.PSObject.Properties) { $out += $p.Name }
|
|
54
|
+
return $out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Get-GlobalPackages {
|
|
58
|
+
<# .SYNOPSIS Packages under one global root. Reads each package.json (top level only) - never walks a global tree. #>
|
|
59
|
+
param([pscustomobject] $RootRow)
|
|
60
|
+
$rows = @()
|
|
61
|
+
if ($RootRow.Style -eq 'bin') {
|
|
62
|
+
foreach ($e in (Get-ChildEntries $RootRow.Root)) {
|
|
63
|
+
if ($e -is [IO.DirectoryInfo]) { continue }
|
|
64
|
+
if ($e.Extension -eq '.cmd') { continue }
|
|
65
|
+
$idle = [int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e)).TotalDays)
|
|
66
|
+
$rows += [pscustomobject]@{ Manager = $RootRow.Manager; Name = [IO.Path]::GetFileNameWithoutExtension($e.Name)
|
|
67
|
+
Version = ''; Path = (Remove-LongPrefix $e.FullName); Bytes = [long]$e.Length; Idle = $idle
|
|
68
|
+
Bins = @([IO.Path]::GetFileNameWithoutExtension($e.Name)) }
|
|
69
|
+
}
|
|
70
|
+
return $rows
|
|
71
|
+
}
|
|
72
|
+
$dirs = @()
|
|
73
|
+
foreach ($e in (Get-ChildEntries $RootRow.Root)) {
|
|
74
|
+
if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
|
|
75
|
+
if ($e.Name -eq '.bin') { continue }
|
|
76
|
+
if ($e.Name.StartsWith('@')) {
|
|
77
|
+
foreach ($s in (Get-ChildEntries (Remove-LongPrefix $e.FullName))) {
|
|
78
|
+
if ($s -is [IO.DirectoryInfo]) { $dirs += [pscustomobject]@{ Name = ($e.Name + '/' + $s.Name); Dir = (Remove-LongPrefix $s.FullName) } }
|
|
79
|
+
}
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
$dirs += [pscustomobject]@{ Name = $e.Name; Dir = (Remove-LongPrefix $e.FullName) }
|
|
83
|
+
}
|
|
84
|
+
foreach ($d in $dirs) {
|
|
85
|
+
$manifest = $null
|
|
86
|
+
$mf = Join-Path $d.Dir 'package.json'
|
|
87
|
+
if (Test-PathPresent $mf) { try { $manifest = @(Read-JsonFile $mf)[0] } catch { $manifest = $null } }
|
|
88
|
+
$version = ''
|
|
89
|
+
if ($manifest -and $manifest.version) { $version = [string]$manifest.version }
|
|
90
|
+
# The newest of the package's own top-level entries: a global node_modules is far too big to walk.
|
|
91
|
+
$newest = [datetime]::MinValue
|
|
92
|
+
foreach ($c in (Get-ChildEntries $d.Dir)) { $t = Get-NewestTimestampUtc $c; if ($t -gt $newest) { $newest = $t } }
|
|
93
|
+
if ($newest -eq [datetime]::MinValue) { $newest = [datetime]::UtcNow }
|
|
94
|
+
$rows += [pscustomobject]@{ Manager = $RootRow.Manager; Name = $d.Name; Version = $version; Path = $d.Dir
|
|
95
|
+
Bytes = [long](Get-DirectoryBytes $d.Dir); Idle = [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
|
|
96
|
+
Bins = @(Get-PackageBinNames -Manifest $manifest -Name $d.Name) }
|
|
97
|
+
}
|
|
98
|
+
return $rows
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Get-ProjectPackageIndex {
|
|
102
|
+
<# .SYNOPSIS What the project roots reference: dependency names, script text and local .bin command names.
|
|
103
|
+
Only manifests touched inside the idle window count - an ancient project does not keep a global alive. #>
|
|
104
|
+
param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 4, [int] $MaxFiles = 2000)
|
|
105
|
+
$names = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
106
|
+
$bins = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
107
|
+
$script = New-Object System.Text.StringBuilder
|
|
108
|
+
$seen = 0
|
|
109
|
+
foreach ($root in $Roots) {
|
|
110
|
+
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
111
|
+
$stack.Push(@($root, 0))
|
|
112
|
+
while ($stack.Count -gt 0 -and $seen -lt $MaxFiles) {
|
|
113
|
+
$item = $stack.Pop()
|
|
114
|
+
$dir = [string]$item[0]; $depth = [int]$item[1]
|
|
115
|
+
foreach ($e in (Get-ChildEntries $dir)) {
|
|
116
|
+
if (Test-ReparsePoint $e) { continue }
|
|
117
|
+
if ($e -is [IO.DirectoryInfo]) {
|
|
118
|
+
if ($e.Name -eq 'node_modules') {
|
|
119
|
+
foreach ($b in (Get-ChildEntries (Join-Path (Remove-LongPrefix $e.FullName) '.bin'))) { $null = $bins.Add([IO.Path]::GetFileNameWithoutExtension($b.Name)) }
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
|
|
123
|
+
if ($depth -lt $MaxDepth) { $stack.Push(@((Remove-LongPrefix $e.FullName), $depth + 1)) }
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if ($e.Name -ne 'package.json') { continue }
|
|
127
|
+
$seen++
|
|
128
|
+
if ([int][math]::Floor(([datetime]::UtcNow - (Get-NewestTimestampUtc $e)).TotalDays) -ge $Days) { continue }
|
|
129
|
+
$m = $null
|
|
130
|
+
try { $m = @(Read-JsonFile (Remove-LongPrefix $e.FullName))[0] } catch { $m = $null }
|
|
131
|
+
if ($null -eq $m) { continue }
|
|
132
|
+
foreach ($field in @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies')) {
|
|
133
|
+
if ($m.$field) { foreach ($p in $m.$field.PSObject.Properties) { $null = $names.Add($p.Name) } }
|
|
134
|
+
}
|
|
135
|
+
if ($m.scripts -and $script.Length -lt 200000) {
|
|
136
|
+
foreach ($p in $m.scripts.PSObject.Properties) { $null = $script.Append(' ').Append([string]$p.Value) }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [pscustomobject]@{ Names = $names; Bins = $bins; ScriptText = $script.ToString().ToLowerInvariant(); Scanned = $seen }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function Get-GlobalPackageVerdict {
|
|
145
|
+
<# .SYNOPSIS Pure verdict for one global package: keep | candidate, plus whether a project shadows its command.
|
|
146
|
+
Candidate needs all three: idle past the window, never referenced, and not a package manager we protect. #>
|
|
147
|
+
param([pscustomobject] $Package, [pscustomobject] $Index, [int] $Days)
|
|
148
|
+
$short = ($Package.Name -split '/')[-1]
|
|
149
|
+
$shadowed = $false
|
|
150
|
+
foreach ($b in @($Package.Bins)) { if ($Index.Bins.Contains($b)) { $shadowed = $true } }
|
|
151
|
+
if ($Script:WS_GLOBAL_KEEP -contains $short -or $Script:WS_GLOBAL_KEEP -contains $Package.Name) {
|
|
152
|
+
return [pscustomobject]@{ Verdict = 'keep'; Reason = 'package manager / this tool'; Shadowed = $shadowed }
|
|
153
|
+
}
|
|
154
|
+
if ($Index.Names.Contains($Package.Name)) { return [pscustomobject]@{ Verdict = 'keep'; Reason = 'a recent project depends on it'; Shadowed = $shadowed } }
|
|
155
|
+
foreach ($b in @($Package.Bins)) {
|
|
156
|
+
if ($b -and $Index.ScriptText.Contains($b.ToLowerInvariant())) { return [pscustomobject]@{ Verdict = 'keep'; Reason = "a recent project script runs '$b'"; Shadowed = $shadowed } }
|
|
157
|
+
}
|
|
158
|
+
if ($Package.Idle -lt $Days) { return [pscustomobject]@{ Verdict = 'keep'; Reason = "touched $($Package.Idle) days ago"; Shadowed = $shadowed } }
|
|
159
|
+
return [pscustomobject]@{ Verdict = 'candidate'; Reason = "nothing referenced it and it is $($Package.Idle) days old"; Shadowed = $shadowed }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Get-GlobalUninstallCommand {
|
|
163
|
+
<# .SYNOPSIS The exact command that removes one global package for its manager. #>
|
|
164
|
+
param([string] $Manager, [string] $Name)
|
|
165
|
+
switch ($Manager) {
|
|
166
|
+
'npm' { return "npm uninstall -g $Name" }
|
|
167
|
+
'pnpm' { return "pnpm remove -g $Name" }
|
|
168
|
+
'yarn' { return "yarn global remove $Name" }
|
|
169
|
+
'bun' { return "bun remove -g $Name" }
|
|
170
|
+
'deno' { return "deno uninstall $Name" }
|
|
171
|
+
default { return "# remove $Name manually" }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function Get-Targets22 {
|
|
176
|
+
<# .SYNOPSIS Informational rows only. Section 22 declares NO deletable target: these roots are protected. #>
|
|
177
|
+
$t = @()
|
|
178
|
+
foreach ($r in (Get-GlobalPackageRoots)) { $t += (New-Target 22 "$($r.Manager) global root" $r.Root -Kind cmd -Note 'read-only audit; this section never deletes') }
|
|
179
|
+
if ($t.Count -eq 0) { $t += (New-Target 22 'global package roots' '(none detected)' -Kind cmd -Note 'read-only audit; this section never deletes') }
|
|
180
|
+
return $t
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function Invoke-Section22 {
|
|
184
|
+
$ws = $Script:WS
|
|
185
|
+
Write-SectionIntro @(
|
|
186
|
+
'What npm, pnpm, yarn, bun and deno installed globally, and which of those nothing has referenced lately.',
|
|
187
|
+
"A package is a candidate only when it is $($ws.Days)+ days old AND no project touched recently names it.",
|
|
188
|
+
'This section never uninstalls anything - it prints the command and leaves the decision to you.'
|
|
189
|
+
)
|
|
190
|
+
$roots = @(Get-GlobalPackageRoots)
|
|
191
|
+
if ($roots.Count -eq 0) { Write-Info 'no global package roots found'; return }
|
|
192
|
+
Write-Info ('roots: ' + (($roots | ForEach-Object { "$($_.Manager) -> $($_.Root)" }) -join ' '))
|
|
193
|
+
$packages = @()
|
|
194
|
+
foreach ($r in $roots) { $packages += @(Get-GlobalPackages -RootRow $r) }
|
|
195
|
+
if ($packages.Count -eq 0) { Write-Info 'no globally installed packages found'; return }
|
|
196
|
+
$projectRoots = @(Get-ProjectScanRoots)
|
|
197
|
+
$index = Get-ProjectPackageIndex -Roots $projectRoots -Days $ws.Days
|
|
198
|
+
Write-Note "checked $($index.Scanned) project manifest(s) under: $($projectRoots -join ' ')"
|
|
199
|
+
$lines = @("global packages audit - $(Get-Date -Format 'yyyy-MM-dd HH:mm') - idle window $($ws.Days) days", '')
|
|
200
|
+
$kept = @(); $cand = @()
|
|
201
|
+
foreach ($p in ($packages | Sort-Object Bytes -Descending)) {
|
|
202
|
+
$v = Get-GlobalPackageVerdict -Package $p -Index $index -Days $ws.Days
|
|
203
|
+
$row = [pscustomobject]@{ Package = $p; Verdict = $v }
|
|
204
|
+
if ($v.Verdict -eq 'candidate') { $cand += $row } else { $kept += $row }
|
|
205
|
+
}
|
|
206
|
+
Write-Section 'Kept'
|
|
207
|
+
Write-UiLine (" {0,-34} {1,-10} {2,10} {3,6} {4}" -f 'PACKAGE', 'VERSION', 'SIZE', 'IDLE', 'WHY') 'White'
|
|
208
|
+
foreach ($r in $kept) {
|
|
209
|
+
$line = " {0,-34} {1,-10} {2,10} {3,5}d {4}" -f $r.Package.Name, $r.Package.Version, (Format-Bytes $r.Package.Bytes), $r.Package.Idle, $r.Verdict.Reason
|
|
210
|
+
Write-UiLine $line 'Gray'; $lines += ('kept ' + $line.Trim())
|
|
211
|
+
}
|
|
212
|
+
Write-Section 'Candidates - nothing recent referenced these'
|
|
213
|
+
if ($cand.Count -eq 0) { Write-Info 'none' } else {
|
|
214
|
+
Write-UiLine (" {0,-34} {1,-10} {2,10} {3,6} {4}" -f 'PACKAGE', 'VERSION', 'SIZE', 'IDLE', 'NOTE') 'White'
|
|
215
|
+
$total = [long]0
|
|
216
|
+
foreach ($r in $cand) {
|
|
217
|
+
$note = ''
|
|
218
|
+
if ($r.Verdict.Shadowed) { $note = 'a project installs this command locally' }
|
|
219
|
+
$line = " {0,-34} {1,-10} {2,10} {3,5}d {4}" -f $r.Package.Name, $r.Package.Version, (Format-Bytes $r.Package.Bytes), $r.Package.Idle, $note
|
|
220
|
+
Write-UiLine $line 'Yellow'; $lines += ('candidate ' + $line.Trim())
|
|
221
|
+
$total += $r.Package.Bytes
|
|
222
|
+
}
|
|
223
|
+
Write-Info ('candidates hold ' + (Format-Bytes $total))
|
|
224
|
+
Write-Section 'Remove them yourself with'
|
|
225
|
+
$lines += ''; $lines += 'uninstall commands:'
|
|
226
|
+
foreach ($r in $cand) {
|
|
227
|
+
$cmd = Get-GlobalUninstallCommand -Manager $r.Package.Manager -Name $r.Package.Name
|
|
228
|
+
Write-Plain " $cmd"; $lines += " $cmd"
|
|
229
|
+
}
|
|
230
|
+
Write-Note 'windowsweep does not run these: a global package can be a command you rely on rarely.'
|
|
231
|
+
}
|
|
232
|
+
if (-not $ws.NoReport) {
|
|
233
|
+
$out = Join-Path $ws.ReportsDir ('global-packages-' + $ws.Stamp + '.txt')
|
|
234
|
+
try { [IO.File]::WriteAllLines($out, $lines); Write-Note "list saved: $out" } catch { $null = $_ }
|
|
235
|
+
}
|
|
236
|
+
}
|
package/modules/health.ps1
CHANGED
|
@@ -76,7 +76,7 @@ function Invoke-Section00 {
|
|
|
76
76
|
} catch { $null = $_ }
|
|
77
77
|
}
|
|
78
78
|
Write-UiLine '' 'Gray'
|
|
79
|
-
try { $n = @(Get-CimInstance Win32_StartupCommand -ErrorAction Stop).Count; Write-Kv 'Startup items:' "$n (
|
|
79
|
+
try { $n = @(Get-CimInstance Win32_StartupCommand -ErrorAction Stop).Count; Write-Kv 'Startup items:' "$n (section 25 lists them all; change them in Task Manager > Startup - this tool never does)" } catch { $null = $_ }
|
|
80
80
|
try {
|
|
81
81
|
$ss = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\StorageSense\Parameters\StoragePolicy' -ErrorAction Stop).'01'
|
|
82
82
|
$ssText = 'off'
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# installed_programs.ps1 - section 24: installed programs nothing has modified for N+ days. Report only.
|
|
2
|
+
#
|
|
3
|
+
# It NEVER runs an uninstaller, in any mode. Windows keeps no reliable "last launched" record, so the number
|
|
4
|
+
# reported is "not modified", derived from the files under InstallLocation - never described as "unused".
|
|
5
|
+
|
|
6
|
+
function Get-WingetIdMap {
|
|
7
|
+
<# .SYNOPSIS One winget call, parsed once: normalised program name -> package id. Empty when winget is absent. #>
|
|
8
|
+
$map = @{}
|
|
9
|
+
if (-not (Test-CommandPresent 'winget')) { return $map }
|
|
10
|
+
$lines = @()
|
|
11
|
+
try { $lines = @(& winget list --disable-interactivity 2>$null) } catch { return $map }
|
|
12
|
+
foreach ($line in $lines) {
|
|
13
|
+
$text = [string]$line
|
|
14
|
+
# "Name Id Version Source" with runs of spaces between columns; ids never contain a space.
|
|
15
|
+
if ($text -notmatch '^(.+?)\s\s+([^\s]+)\s\s+') { continue }
|
|
16
|
+
$name = ConvertTo-MatchToken $Matches[1]
|
|
17
|
+
$id = $Matches[2]
|
|
18
|
+
if ($name.Length -lt 3 -or $id -eq 'Id') { continue }
|
|
19
|
+
if (-not $map.ContainsKey($name)) { $map[$name] = $id }
|
|
20
|
+
}
|
|
21
|
+
return $map
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Get-InstallLocationIdleDays {
|
|
25
|
+
<# .SYNOPSIS Days since the newest LAST-WRITE at depth <= 2 under an install folder, or -1 when unknowable.
|
|
26
|
+
Deliberately NOT Get-NewestTimestampUtc, which the cleanup sections use: that takes the newest of write,
|
|
27
|
+
access and creation, and last-access is live on this machine - measured 2026-09-04, every install folder
|
|
28
|
+
read as 0 days idle that way (Git, Gpg4win, RustDesk, Malwarebytes all 0d) while last-write gave 120d,
|
|
29
|
+
203d, 163d and 148d. This section asks "not modified", so last-write is the matching question. #>
|
|
30
|
+
param([string] $Location, [int] $MaxEntries = 4000)
|
|
31
|
+
if ([string]::IsNullOrWhiteSpace($Location)) { return -1 }
|
|
32
|
+
$root = Get-FullPath ($Location.Trim().Trim('"').TrimEnd('\'))
|
|
33
|
+
if (-not $root -or -not (Test-DirPresent $root)) { return -1 }
|
|
34
|
+
$newest = [datetime]::MinValue
|
|
35
|
+
$seen = 0
|
|
36
|
+
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
37
|
+
$stack.Push(@($root, 0))
|
|
38
|
+
while ($stack.Count -gt 0 -and $seen -lt $MaxEntries) {
|
|
39
|
+
$item = $stack.Pop()
|
|
40
|
+
foreach ($e in (Get-ChildEntries ([string]$item[0]))) {
|
|
41
|
+
$seen++
|
|
42
|
+
if (Test-ReparsePoint $e) { continue }
|
|
43
|
+
if ($e -is [IO.DirectoryInfo]) {
|
|
44
|
+
if ([int]$item[1] -lt 2) { $stack.Push(@((Remove-LongPrefix $e.FullName), [int]$item[1] + 1)) }
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if ($e.LastWriteTimeUtc -gt $newest) { $newest = $e.LastWriteTimeUtc }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if ($newest -eq [datetime]::MinValue) { return -1 }
|
|
51
|
+
return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Get-ProgramRemovalHint {
|
|
55
|
+
<# .SYNOPSIS The command that removes a program: winget when it knows it, otherwise its own UninstallString. #>
|
|
56
|
+
param([pscustomobject] $Row, [hashtable] $WingetMap)
|
|
57
|
+
$key = ConvertTo-MatchToken $Row.DisplayName
|
|
58
|
+
if ($WingetMap.ContainsKey($key)) { return "winget uninstall --id $($WingetMap[$key])" }
|
|
59
|
+
if ($Row.UninstallString) { return $Row.UninstallString }
|
|
60
|
+
return '(no uninstall command recorded - remove it from Settings > Apps)'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Get-Targets24 {
|
|
64
|
+
<# .SYNOPSIS Informational only. Section 24 declares nothing deletable: Program Files is protected. #>
|
|
65
|
+
return @((New-Target 24 'Installed programs (registry)' 'HKLM + HKCU Uninstall hives' -Kind cmd -Note 'report only; this section never runs an uninstaller'))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Invoke-Section24 {
|
|
69
|
+
$ws = $Script:WS
|
|
70
|
+
Write-SectionIntro @(
|
|
71
|
+
'Programs whose files nothing has modified for a long time, largest first, with the command that removes',
|
|
72
|
+
'each one. Windows keeps no reliable record of when a program last RAN, so this is "not modified", not',
|
|
73
|
+
'"not used" - check the list before acting. This section never runs an uninstaller.'
|
|
74
|
+
)
|
|
75
|
+
$rows = @(Get-InstalledProgramRows | Where-Object { $_.SystemComponent -ne 1 })
|
|
76
|
+
if ($rows.Count -eq 0) { Write-Warn 'no uninstall entries could be read'; return }
|
|
77
|
+
Write-Note "$($rows.Count) installed program(s) found; measuring the ones with an install folder"
|
|
78
|
+
$winget = Get-WingetIdMap
|
|
79
|
+
$scored = @()
|
|
80
|
+
foreach ($r in $rows) {
|
|
81
|
+
$idle = Get-InstallLocationIdleDays -Location $r.InstallLocation
|
|
82
|
+
if ($idle -lt $ws.Days) { continue }
|
|
83
|
+
$scored += [pscustomobject]@{ Row = $r; Idle = $idle; Bytes = ([long]$r.EstimatedSizeKb * 1024) }
|
|
84
|
+
}
|
|
85
|
+
$lines = @("installed programs not modified for $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
|
|
86
|
+
if ($scored.Count -eq 0) {
|
|
87
|
+
Write-Info "no installed program has been untouched for $($ws.Days)+ days"
|
|
88
|
+
} else {
|
|
89
|
+
Write-UiLine (" {0,-44} {1,-14} {2,10} {3,6} {4}" -f 'PROGRAM', 'VERSION', 'SIZE', 'IDLE', 'PUBLISHER') 'White'
|
|
90
|
+
foreach ($s in ($scored | Sort-Object Bytes -Descending)) {
|
|
91
|
+
$size = 'unknown'
|
|
92
|
+
if ($s.Bytes -gt 0) { $size = Format-Bytes $s.Bytes }
|
|
93
|
+
$line = " {0,-44} {1,-14} {2,10} {3,5}d {4}" -f $s.Row.DisplayName, $s.Row.DisplayVersion, $size, $s.Idle, $s.Row.Publisher
|
|
94
|
+
Write-UiLine $line 'Gray'
|
|
95
|
+
$lines += $line.Trim()
|
|
96
|
+
$lines += (' remove with: ' + (Get-ProgramRemovalHint -Row $s.Row -WingetMap $winget))
|
|
97
|
+
}
|
|
98
|
+
Write-Section 'Remove one yourself with'
|
|
99
|
+
foreach ($s in ($scored | Sort-Object Bytes -Descending | Select-Object -First 10)) {
|
|
100
|
+
Write-Plain (' ' + (Get-ProgramRemovalHint -Row $s.Row -WingetMap $winget))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
Write-Section 'Store apps (not system packages)'
|
|
104
|
+
$appx = @()
|
|
105
|
+
try { $appx = @(Get-AppxPackage -ErrorAction SilentlyContinue | Where-Object { -not $_.IsFramework -and $_.SignatureKind -ne 'System' }) } catch { $appx = @() }
|
|
106
|
+
if ($appx.Count -eq 0) { Write-Info 'none found' } else {
|
|
107
|
+
foreach ($a in ($appx | Sort-Object Name)) {
|
|
108
|
+
Write-UiLine (" {0,-52} {1}" -f $a.Name, $a.Version) 'Gray'
|
|
109
|
+
$lines += ("store {0} {1}" -f $a.Name, $a.Version)
|
|
110
|
+
$lines += (' remove with: Remove-AppxPackage -Package ' + $a.PackageFullName)
|
|
111
|
+
}
|
|
112
|
+
Write-Note 'remove one with: Remove-AppxPackage -Package <PackageFullName>'
|
|
113
|
+
}
|
|
114
|
+
if (-not $ws.NoReport) {
|
|
115
|
+
$out = Join-Path $ws.ReportsDir ('installed-programs-' + $ws.Stamp + '.txt')
|
|
116
|
+
try { [IO.File]::WriteAllLines($out, $lines); Write-Note "list saved: $out" } catch { $null = $_ }
|
|
117
|
+
}
|
|
118
|
+
}
|
package/modules/menu.ps1
CHANGED
|
@@ -37,7 +37,9 @@ function Invoke-Menu {
|
|
|
37
37
|
}
|
|
38
38
|
while ($true) {
|
|
39
39
|
Show-Menu
|
|
40
|
-
$
|
|
40
|
+
$lo = ($Script:WS_SECTIONS | Measure-Object Id -Minimum).Minimum
|
|
41
|
+
$hi = ($Script:WS_SECTIONS | Measure-Object Id -Maximum).Maximum
|
|
42
|
+
$choice = Read-Choice -Prompt " Select [$lo-$hi / A / D / Y / S / Q]: " -Default ''
|
|
41
43
|
if ($choice -eq '') { continue }
|
|
42
44
|
switch -Regex ($choice) {
|
|
43
45
|
'^q' { Show-SessionSummary; return }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# notify.ps1 - --notify: one Windows notification when a run ends.
|
|
2
|
+
#
|
|
3
|
+
# Off by default. --quiet still notifies (that is the point of the flag when a Scheduled Task runs it).
|
|
4
|
+
# Every path is wrapped: a notification that cannot be shown is not a failed run, so this NEVER touches the
|
|
5
|
+
# exit code and NEVER writes to stdout - --json promises exactly one line there.
|
|
6
|
+
|
|
7
|
+
function Get-RunNotificationText {
|
|
8
|
+
<# .SYNOPSIS The two lines a notification carries. Pure, so the self-test can read it without a toast. #>
|
|
9
|
+
$ws = $Script:WS
|
|
10
|
+
$ran = @($ws.Report.steps | Where-Object { $_.status -in 'ran', 'dry-run' }).Count
|
|
11
|
+
if ($ws.DryRun) {
|
|
12
|
+
return [pscustomobject]@{ Title = "$Script:WS_NAME - dry run finished"; Body = ("about " + (Format-Bytes $ws.TotalEstimated) + " could be freed across $ran section(s)") }
|
|
13
|
+
}
|
|
14
|
+
return [pscustomobject]@{ Title = "$Script:WS_NAME finished"; Body = ("freed " + (Format-Bytes $ws.TotalFreed) + " across $ran section(s)") }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function Show-ToastNotification {
|
|
18
|
+
<# .SYNOPSIS Windows PowerShell 5.1: a real toast through WinRT, under the PowerShell AppUserModelId. #>
|
|
19
|
+
param([string] $Title, [string] $Body)
|
|
20
|
+
$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
|
|
21
|
+
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
22
|
+
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
|
|
23
|
+
$tpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
24
|
+
$texts = $tpl.GetElementsByTagName('text')
|
|
25
|
+
$null = $texts.Item(0).AppendChild($tpl.CreateTextNode($Title))
|
|
26
|
+
$null = $texts.Item(1).AppendChild($tpl.CreateTextNode($Body))
|
|
27
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification($tpl)
|
|
28
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Show-BalloonNotification {
|
|
32
|
+
<# .SYNOPSIS PowerShell 7 has no WinRT projection by default: a tray balloon instead. #>
|
|
33
|
+
param([string] $Title, [string] $Body)
|
|
34
|
+
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
35
|
+
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
|
|
36
|
+
$icon = New-Object System.Windows.Forms.NotifyIcon
|
|
37
|
+
try {
|
|
38
|
+
$icon.Icon = [System.Drawing.SystemIcons]::Information
|
|
39
|
+
$icon.Visible = $true
|
|
40
|
+
$icon.ShowBalloonTip(5000, $Title, $Body, [System.Windows.Forms.ToolTipIcon]::Info)
|
|
41
|
+
# The balloon needs the tray icon to survive a moment; without this it is created and destroyed unseen.
|
|
42
|
+
Start-Sleep -Milliseconds 600
|
|
43
|
+
} finally {
|
|
44
|
+
$icon.Visible = $false
|
|
45
|
+
$icon.Dispose()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Send-RunNotification {
|
|
50
|
+
<# .SYNOPSIS --notify: show the end-of-run notification, or say quietly in the log why it could not. #>
|
|
51
|
+
if (-not $Script:WS.Notify) { return }
|
|
52
|
+
$text = Get-RunNotificationText
|
|
53
|
+
try {
|
|
54
|
+
if ($PSVersionTable.PSEdition -eq 'Core') { Show-BalloonNotification -Title $text.Title -Body $text.Body }
|
|
55
|
+
else { Show-ToastNotification -Title $text.Title -Body $text.Body }
|
|
56
|
+
Write-LogLine "notification shown: $($text.Title) - $($text.Body)"
|
|
57
|
+
} catch {
|
|
58
|
+
Write-LogLine "notification could not be shown (this never affects the exit code): $($_.Exception.Message)"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|