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
@@ -1,93 +1,93 @@
1
- # docker.ps1 - section 5 (Docker prune) and section 20 (disk image compaction).
2
-
3
- function Get-Targets05 {
4
- return @(
5
- (New-Target 5 'docker image prune (dangling layers)' 'docker image prune -f' -Kind cmd)
6
- (New-Target 5 'docker builder prune (build cache idle N+ days)' 'docker builder prune -f --filter until=<N>h' -Kind cmd)
7
- (New-Target 5 'docker image prune (unused images older than N days)' 'docker image prune -a -f --filter until=<N>h' -Kind cmd)
8
- )
9
- }
10
-
11
- function Test-DockerDaemon {
12
- if (-not (Test-CommandPresent 'docker')) { return $false }
13
- $r = Invoke-External -FilePath 'docker' -ArgumentList @('info', '--format', '{{.ServerVersion}}') -Quiet -Label 'docker info'
14
- return ($r.Ran -and $r.ExitCode -eq 0)
15
- }
16
-
17
- function Invoke-DockerPrune {
18
- param([string[]] $Arguments, [string] $Label)
19
- $r = Invoke-External -FilePath 'docker' -ArgumentList $Arguments -Destructive -Quiet -Label $Label
20
- if (-not $r.Ran) { return [long]0 }
21
- $freed = [long]0
22
- foreach ($l in $r.Output) { if ($l -match 'Total reclaimed space:\s*(.+)$') { $freed += ConvertFrom-SizeText $Matches[1] } }
23
- if ($r.ExitCode -eq 0) { Write-Ok ("$Label - reclaimed " + (Format-Bytes $freed)) } else { Write-Warn "$Label - docker returned $($r.ExitCode)" }
24
- Add-Freed $freed
25
- return $freed
26
- }
27
-
28
- function Invoke-Section05 {
29
- $ws = $Script:WS
30
- Write-SectionIntro @(
31
- 'Docker keeps every image layer and build step it ever produced. Dangling layers and the build cache are',
32
- 'safe to drop; images no container uses go only when older than the idle window. Volumes are never touched',
33
- '(review them yourself with: docker volume ls / docker volume prune).'
34
- ) -Dev $true
35
- if (-not (Test-CommandPresent 'docker')) { Write-Info 'docker CLI not found - skipped'; return }
36
- if (-not (Test-DockerDaemon)) { Write-Warn 'Docker daemon is not running (start Docker Desktop) - skipped'; $ws.Hints += 'Start Docker Desktop and re-run: windowsweep --only 5 --yes'; return }
37
- $df = Invoke-External -FilePath 'docker' -ArgumentList @('system', 'df') -Quiet -Label 'docker system df'
38
- foreach ($l in $df.Output) { Write-Plain " $l" }
39
- if (-not (Confirm-Section 'Prune Docker now?')) { Write-Info 'skipped'; return }
40
- $hours = [int]($ws.Days * 24)
41
- if ($ws.PurgeAll -or $ws.Developer -eq $false) {
42
- if ($ws.Developer -ne $false -and -not (Confirm-Ui -Prompt 'FULL PURGE removes every image no container uses right now (they re-pull later). Continue?' -Default 'n')) { Write-Info 'full purge declined; doing the conservative prune instead' }
43
- else { $null = Invoke-DockerPrune -Arguments @('system', 'prune', '-a', '-f') -Label 'docker system prune -a'; return }
44
- }
45
- $null = Invoke-DockerPrune -Arguments @('image', 'prune', '-f') -Label 'dangling image layers'
46
- $null = Invoke-DockerPrune -Arguments @('builder', 'prune', '-f', '--filter', "until=${hours}h") -Label "build cache idle $($ws.Days)+ days"
47
- $null = Invoke-DockerPrune -Arguments @('image', 'prune', '-a', '-f', '--filter', "until=${hours}h") -Label "unused images older than $($ws.Days) days"
48
- Write-Note 'the Docker disk image (docker_data.vhdx) does not shrink by itself - section 20 compacts it (admin, stops Docker)'
49
- }
50
-
51
- # ---------------------------------------------------------------------------------------------
52
- # Section 20 - disk image compaction
53
- # ---------------------------------------------------------------------------------------------
54
-
55
- function Get-Targets20 {
56
- return @((New-Target 20 'Docker Desktop / WSL virtual disks (*.vhdx)' "$($Script:P.L)\Docker\wsl\*\*.vhdx and Packages\*\LocalState\*.vhdx" -Kind cmd -Note 'diskpart compact vdisk; Docker Desktop and WSL are stopped first'))
57
- }
58
-
59
- function Invoke-Section20 {
60
- $ws = $Script:WS
61
- Write-SectionIntro @(
62
- 'A WSL/Docker virtual disk grows as data is written but never shrinks when it is deleted. Compaction hands the',
63
- 'free space back to Windows. It needs Administrator rights and STOPS Docker Desktop and every WSL distro first;',
64
- 'restart Docker Desktop afterwards.'
65
- )
66
- $images = @(Get-DiskImageFiles)
67
- if ($images.Count -eq 0) { Write-Info 'no .vhdx disk images found'; return }
68
- $i = 1
69
- foreach ($img in $images) { Write-UiLine (" {0,3} {1,10} {2}" -f $i, (Format-Bytes $img.Length), $img.FullName) 'Gray'; $i++ }
70
- if (-not (Test-CommandPresent 'diskpart.exe')) { Write-Warn 'diskpart.exe not found - skipped'; return }
71
- $picks = @()
72
- if ($ws.Yes) { $picks = @(1..$images.Count) } else { $picks = @(Read-MultiSelect -Total $images.Count -NoAutoYes) }
73
- if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
74
- if (-not (Confirm-Section 'Stop Docker Desktop and WSL now and compact the selected disks?' 'n')) { Write-Info 'skipped'; return }
75
- if ($ws.DryRun) { foreach ($k in $picks) { Write-DryRun "would compact $($images[$k - 1].FullName)" }; return }
76
- Write-Info 'stopping Docker Desktop and WSL...'
77
- Get-Process -Name 'Docker Desktop' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
78
- $null = Invoke-External -FilePath 'wsl.exe' -ArgumentList @('--shutdown') -Destructive -Quiet -Label 'wsl --shutdown'
79
- Start-Sleep -Seconds 8
80
- foreach ($k in $picks) {
81
- $img = $images[$k - 1]
82
- $before = (Get-Item -LiteralPath $img.FullName).Length
83
- $script = Join-Path $env:TEMP ("windowsweep-diskpart-" + [guid]::NewGuid().ToString('N') + '.txt')
84
- [IO.File]::WriteAllLines($script, @("select vdisk file=`"$($img.FullName)`"", 'attach vdisk readonly', 'compact vdisk', 'detach vdisk', 'exit'))
85
- $r = Invoke-External -FilePath 'diskpart.exe' -ArgumentList @('/s', $script) -Destructive -Quiet -Label "diskpart compact $($img.Name)"
86
- Remove-Item -LiteralPath $script -Force -ErrorAction SilentlyContinue
87
- $after = (Get-Item -LiteralPath $img.FullName).Length
88
- $gain = [math]::Max([long]0, $before - $after)
89
- if ($r.ExitCode -eq 0) { Write-Ok ("$($img.Name): " + (Format-Bytes $before) + ' -> ' + (Format-Bytes $after) + ' (freed ' + (Format-Bytes $gain) + ')') } else { Write-Warn "$($img.Name): diskpart returned $($r.ExitCode)"; foreach ($l in $r.Output) { Write-Note $l } }
90
- Add-Freed $gain
91
- }
92
- Write-Info 'done - start Docker Desktop again when you need it'
93
- }
1
+ # docker.ps1 - section 5 (Docker prune) and section 20 (disk image compaction).
2
+
3
+ function Get-Targets05 {
4
+ return @(
5
+ (New-Target 5 'docker image prune (dangling layers)' 'docker image prune -f' -Kind cmd)
6
+ (New-Target 5 'docker builder prune (build cache idle N+ days)' 'docker builder prune -f --filter until=<N>h' -Kind cmd)
7
+ (New-Target 5 'docker image prune (unused images older than N days)' 'docker image prune -a -f --filter until=<N>h' -Kind cmd)
8
+ )
9
+ }
10
+
11
+ function Test-DockerDaemon {
12
+ if (-not (Test-CommandPresent 'docker')) { return $false }
13
+ $r = Invoke-External -FilePath 'docker' -ArgumentList @('info', '--format', '{{.ServerVersion}}') -Quiet -Label 'docker info'
14
+ return ($r.Ran -and $r.ExitCode -eq 0)
15
+ }
16
+
17
+ function Invoke-DockerPrune {
18
+ param([string[]] $Arguments, [string] $Label)
19
+ $r = Invoke-External -FilePath 'docker' -ArgumentList $Arguments -Destructive -Quiet -Label $Label
20
+ if (-not $r.Ran) { return [long]0 }
21
+ $freed = [long]0
22
+ foreach ($l in $r.Output) { if ($l -match 'Total reclaimed space:\s*(.+)$') { $freed += ConvertFrom-SizeText $Matches[1] } }
23
+ if ($r.ExitCode -eq 0) { Write-Ok ("$Label - reclaimed " + (Format-Bytes $freed)) } else { Write-Warn "$Label - docker returned $($r.ExitCode)" }
24
+ Add-Freed $freed
25
+ return $freed
26
+ }
27
+
28
+ function Invoke-Section05 {
29
+ $ws = $Script:WS
30
+ Write-SectionIntro @(
31
+ 'Docker keeps every image layer and build step it ever produced. Dangling layers and the build cache are',
32
+ 'safe to drop; images no container uses go only when older than the idle window. Volumes are never touched',
33
+ '(review them yourself with: docker volume ls / docker volume prune).'
34
+ ) -Dev $true
35
+ if (-not (Test-CommandPresent 'docker')) { Write-Info 'docker CLI not found - skipped'; return }
36
+ if (-not (Test-DockerDaemon)) { Write-Warn 'Docker daemon is not running (start Docker Desktop) - skipped'; $ws.Hints += 'Start Docker Desktop and re-run: windowsweep --only 5 --yes'; return }
37
+ $df = Invoke-External -FilePath 'docker' -ArgumentList @('system', 'df') -Quiet -Label 'docker system df'
38
+ foreach ($l in $df.Output) { Write-Plain " $l" }
39
+ if (-not (Confirm-Section 'Prune Docker now?')) { Write-Info 'skipped'; return }
40
+ $hours = [int]($ws.Days * 24)
41
+ if ($ws.PurgeAll -or $ws.Developer -eq $false) {
42
+ if ($ws.Developer -ne $false -and -not (Confirm-Ui -Prompt 'FULL PURGE removes every image no container uses right now (they re-pull later). Continue?' -Default 'n')) { Write-Info 'full purge declined; doing the conservative prune instead' }
43
+ else { $null = Invoke-DockerPrune -Arguments @('system', 'prune', '-a', '-f') -Label 'docker system prune -a'; return }
44
+ }
45
+ $null = Invoke-DockerPrune -Arguments @('image', 'prune', '-f') -Label 'dangling image layers'
46
+ $null = Invoke-DockerPrune -Arguments @('builder', 'prune', '-f', '--filter', "until=${hours}h") -Label "build cache idle $($ws.Days)+ days"
47
+ $null = Invoke-DockerPrune -Arguments @('image', 'prune', '-a', '-f', '--filter', "until=${hours}h") -Label "unused images older than $($ws.Days) days"
48
+ Write-Note 'the Docker disk image (docker_data.vhdx) does not shrink by itself - section 20 compacts it (admin, stops Docker)'
49
+ }
50
+
51
+ # ---------------------------------------------------------------------------------------------
52
+ # Section 20 - disk image compaction
53
+ # ---------------------------------------------------------------------------------------------
54
+
55
+ function Get-Targets20 {
56
+ return @((New-Target 20 'Docker Desktop / WSL virtual disks (*.vhdx)' "$($Script:P.L)\Docker\wsl\*\*.vhdx and Packages\*\LocalState\*.vhdx" -Kind cmd -Note 'diskpart compact vdisk; Docker Desktop and WSL are stopped first'))
57
+ }
58
+
59
+ function Invoke-Section20 {
60
+ $ws = $Script:WS
61
+ Write-SectionIntro @(
62
+ 'A WSL/Docker virtual disk grows as data is written but never shrinks when it is deleted. Compaction hands the',
63
+ 'free space back to Windows. It needs Administrator rights and STOPS Docker Desktop and every WSL distro first;',
64
+ 'restart Docker Desktop afterwards.'
65
+ )
66
+ $images = @(Get-DiskImageFiles)
67
+ if ($images.Count -eq 0) { Write-Info 'no .vhdx disk images found'; return }
68
+ $i = 1
69
+ foreach ($img in $images) { Write-UiLine (" {0,3} {1,10} {2}" -f $i, (Format-Bytes $img.Length), $img.FullName) 'Gray'; $i++ }
70
+ if (-not (Test-CommandPresent 'diskpart.exe')) { Write-Warn 'diskpart.exe not found - skipped'; return }
71
+ $picks = @()
72
+ if ($ws.Yes) { $picks = @(1..$images.Count) } else { $picks = @(Read-MultiSelect -Total $images.Count -NoAutoYes) }
73
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
74
+ if (-not (Confirm-Section 'Stop Docker Desktop and WSL now and compact the selected disks?' 'n')) { Write-Info 'skipped'; return }
75
+ if ($ws.DryRun) { foreach ($k in $picks) { Write-DryRun "would compact $($images[$k - 1].FullName)" }; return }
76
+ Write-Info 'stopping Docker Desktop and WSL...'
77
+ Get-Process -Name 'Docker Desktop' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
78
+ $null = Invoke-External -FilePath 'wsl.exe' -ArgumentList @('--shutdown') -Destructive -Quiet -Label 'wsl --shutdown'
79
+ Start-Sleep -Seconds 8
80
+ foreach ($k in $picks) {
81
+ $img = $images[$k - 1]
82
+ $before = (Get-Item -LiteralPath $img.FullName).Length
83
+ $script = Join-Path $env:TEMP ("windowsweep-diskpart-" + [guid]::NewGuid().ToString('N') + '.txt')
84
+ [IO.File]::WriteAllLines($script, @("select vdisk file=`"$($img.FullName)`"", 'attach vdisk readonly', 'compact vdisk', 'detach vdisk', 'exit'))
85
+ $r = Invoke-External -FilePath 'diskpart.exe' -ArgumentList @('/s', $script) -Destructive -Quiet -Label "diskpart compact $($img.Name)"
86
+ Remove-Item -LiteralPath $script -Force -ErrorAction SilentlyContinue
87
+ $after = (Get-Item -LiteralPath $img.FullName).Length
88
+ $gain = [math]::Max([long]0, $before - $after)
89
+ if ($r.ExitCode -eq 0) { Write-Ok ("$($img.Name): " + (Format-Bytes $before) + ' -> ' + (Format-Bytes $after) + ' (freed ' + (Format-Bytes $gain) + ')') } else { Write-Warn "$($img.Name): diskpart returned $($r.ExitCode)"; foreach ($l in $r.Output) { Write-Note $l } }
90
+ Add-Freed $gain
91
+ }
92
+ Write-Info 'done - start Docker Desktop again when you need it'
93
+ }
@@ -1,150 +1,150 @@
1
- # editors.ps1 - section 6: editor caches, stale workspace storage, superseded extension versions.
2
-
3
- $Script:WS_EDITORS = @(
4
- @{ Name = 'VS Code'; Root = 'Code'; Ext = '.vscode'; Proc = @('Code') }
5
- @{ Name = 'VS Code Insiders'; Root = 'Code - Insiders'; Ext = '.vscode-insiders'; Proc = @('Code - Insiders') }
6
- @{ Name = 'VSCodium'; Root = 'VSCodium'; Ext = '.vscode-oss'; Proc = @('VSCodium') }
7
- @{ Name = 'Cursor'; Root = 'Cursor'; Ext = '.cursor'; Proc = @('Cursor') }
8
- @{ Name = 'Windsurf'; Root = 'Windsurf'; Ext = '.windsurf'; Proc = @('Windsurf') }
9
- )
10
-
11
- function Get-Targets06 {
12
- $P = $Script:P
13
- $t = @()
14
- foreach ($e in $Script:WS_EDITORS) {
15
- $root = Join-Path $P.A $e.Root
16
- $t += (New-Target 6 "$($e.Name) caches" $root -Kind editor -Guard $e.Proc -Note 'Cache, CachedData, Code Cache, GPUCache, crash reports; skipped while the editor runs')
17
- $t += (New-Target 6 "$($e.Name) VSIX download cache" (Join-Path $root 'CachedExtensionVSIXs') -Mode clear -Note 'extension installers already unpacked; safe while the editor runs')
18
- $t += (New-Target 6 "$($e.Name) logs" (Join-Path $root 'logs') -Mode clear-old -Days 7)
19
- $t += (New-Target 6 "$($e.Name) stale workspace storage" (Join-Path $root 'User\workspaceStorage') -Kind cmd -Guard $e.Proc -Note 'entries whose folder no longer exists')
20
- $t += (New-Target 6 "$($e.Name) superseded extension versions" (Join-Path $P.U "$($e.Ext)\extensions") -Kind cmd -Guard $e.Proc -Note 'older versions and .obsolete entries; newest kept')
21
- }
22
- $t += (New-Target 6 'Visual Studio ComponentModelCache' "$($P.L)\Microsoft\VisualStudio\*\ComponentModelCache" -Kind glob -Mode clear -Guard @('devenv'))
23
- $t += (New-Target 6 'Visual Studio designer shadow cache' "$($P.L)\Microsoft\VisualStudio\*\Designer\ShadowCache" -Kind glob -Mode clear -Guard @('devenv'))
24
- $t += (New-Target 6 'Visual Studio telemetry (AppInsights)' "$($P.L)\Microsoft\VSApplicationInsights" -Mode clear)
25
- $t += (New-Target 6 'Visual Studio SQM logs' "$($P.L)\Microsoft\VSCommon\*\SQM" -Kind glob -Mode clear)
26
- return $t
27
- }
28
-
29
- function Remove-StaleWorkspaceStorage {
30
- <# .SYNOPSIS workspaceStorage entries pointing at a folder/workspace file that no longer exists. #>
31
- param([string] $Root, [string] $Label)
32
- if (-not (Test-DirPresent $Root)) { return }
33
- $stale = @()
34
- foreach ($e in (Get-ChildEntries $Root)) {
35
- if (-not ($e -is [IO.DirectoryInfo])) { continue }
36
- $json = Join-Path (Remove-LongPrefix $e.FullName) 'workspace.json'
37
- if (-not (Test-PathPresent $json)) { continue }
38
- try {
39
- $w = Get-Content -LiteralPath $json -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
40
- $uri = $w.folder
41
- if (-not $uri) { $uri = $w.workspace }
42
- if (-not $uri -or $uri -notmatch '^file:///') { continue }
43
- $local = [Uri]::UnescapeDataString(($uri -replace '^file:///', '')) -replace '/', '\'
44
- if (-not (Test-PathPresent $local)) { $stale += [pscustomobject]@{ Path = (Remove-LongPrefix $e.FullName); Target = $local } }
45
- } catch { $null = $_ }
46
- }
47
- if ($stale.Count -eq 0) { Write-Info "$Label - none stale"; return }
48
- Write-Info "$Label - $($stale.Count) entries point at folders that no longer exist:"
49
- foreach ($s in $stale) { Write-Note " $($s.Target)" }
50
- foreach ($s in $stale) { $null = Remove-PathSafe -Path $s.Path -Within $Root -Label $Label }
51
- if (-not $Script:WS.DryRun) { Write-Ok "$Label - removed $($stale.Count) stale entries" }
52
- }
53
-
54
- function Read-JsonFile {
55
- <# .SYNOPSIS Parse a JSON file. A top-level array always comes back as an array (PowerShell 5.1's ConvertFrom-Json
56
- emits an array as ONE pipeline object, which silently turns 16 entries into a single bogus one when wrapped). #>
57
- param([string] $Path)
58
- $parsed = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
59
- if ($parsed -is [array]) { return $parsed }
60
- return @($parsed)
61
- }
62
-
63
- function Get-SupersededExtensionPlan {
64
- <# .SYNOPSIS Decide which extension folders are leftovers. Pure logic (exercised by the self-test).
65
- A folder is a victim only when the editor's extensions.json does NOT reference it AND either the .obsolete marker
66
- names it or a different version of the same extension IS referenced. Without extensions.json: .obsolete entries
67
- plus every version older than the newest one present. #>
68
- param([string] $Root, [object[]] $Folders)
69
- $victims = @()
70
- $obsolete = @{}
71
- $obsFile = Join-Path $Root '.obsolete'
72
- if (Test-PathPresent $obsFile) {
73
- try { $o = Get-Content -LiteralPath $obsFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop; foreach ($p in $o.PSObject.Properties) { if ($p.Value) { $obsolete[$p.Name] = $true } } } catch { $null = $_ }
74
- }
75
- $installed = $null
76
- $manifest = Join-Path $Root 'extensions.json'
77
- if (Test-PathPresent $manifest) {
78
- try {
79
- $installed = @{}
80
- foreach ($entry in (Read-JsonFile $manifest)) {
81
- $loc = $entry.relativeLocation
82
- if ($loc -is [string] -and $loc) { $installed[$loc] = $true }
83
- }
84
- } catch { $installed = $null }
85
- }
86
- if ($null -ne $installed -and $installed.Count -gt 0) {
87
- $installedIds = @{}
88
- foreach ($f in $Folders) { if ($installed.ContainsKey($f.Name)) { $installedIds[$f.Id] = $true } }
89
- foreach ($f in $Folders) {
90
- if ($installed.ContainsKey($f.Name)) { continue }
91
- if ($obsolete.ContainsKey($f.Name) -or $installedIds.ContainsKey($f.Id)) { $victims += $f }
92
- }
93
- $note = "extensions.json lists $($installed.Count) installed extension(s); $($victims.Count) leftover folder(s)"
94
- } else {
95
- foreach ($group in @($Folders | Where-Object { -not $obsolete.ContainsKey($_.Name) } | Group-Object Id)) {
96
- if ($group.Count -lt 2) { continue }
97
- $newest = $group.Group | Sort-Object Version -Descending | Select-Object -First 1
98
- foreach ($v in $group.Group) { if ($v.Path -ne $newest.Path) { $victims += $v } }
99
- }
100
- foreach ($f in $Folders) { if ($obsolete.ContainsKey($f.Name)) { $victims += $f } }
101
- $note = "no extensions.json; using .obsolete + newest-version rule: $($victims.Count) leftover folder(s)"
102
- }
103
- return [pscustomobject]@{ Victims = @($victims); Note = $note }
104
- }
105
-
106
- function Remove-SupersededExtensions {
107
- <# .SYNOPSIS Extension folders the editor no longer uses.
108
- The editor's own extensions.json is the source of truth: every versioned folder it does not reference is a leftover
109
- (uninstalled, or superseded by the version it does reference). Without that file, fall back to the .obsolete marker
110
- plus keep-the-newest-version-per-extension. #>
111
- param([string] $Root, [string] $Label)
112
- if (-not (Test-DirPresent $Root)) { return }
113
- $folders = @()
114
- foreach ($e in @(Get-ChildEntries $Root)) {
115
- if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
116
- if ($e.Name -match '^(.+?)-(\d+\.\d+\.\d+)([^\\]*)$') {
117
- $ver = $null
118
- try { $ver = [version]$Matches[2] } catch { continue }
119
- $folders += [pscustomobject]@{ Name = $e.Name; Id = $Matches[1]; Path = (Remove-LongPrefix $e.FullName); Version = $ver }
120
- }
121
- }
122
- if ($folders.Count -eq 0) { Write-Info "$Label - none superseded"; return }
123
- $plan = Get-SupersededExtensionPlan -Root $Root -Folders $folders
124
- Write-Note "$Label - $($plan.Note)"
125
- $victims = @($plan.Victims)
126
- if ($victims.Count -eq 0) { Write-Info "$Label - none superseded"; return }
127
- foreach ($v in $victims) {
128
- $r = Remove-PathSafe -Path $v.Path -Within $Root -Label $Label
129
- if ($r.Removed -and -not $Script:WS.DryRun) { Write-Ok ("$Label - removed $($v.Name) (" + (Format-Bytes $r.Bytes) + ')') }
130
- }
131
- }
132
-
133
- function Invoke-Section06 {
134
- Write-SectionIntro @(
135
- 'Editors rebuild their caches on the next start. Settings, keybindings, snippets, globalStorage and local',
136
- 'History are protected. A running editor is left alone except for its VSIX download cache and old logs.'
137
- )
138
- $targets = Get-Targets06
139
- $null = Show-TargetSizes $targets
140
- if (-not (Confirm-Section 'Clean editor caches now?')) { Write-Info 'skipped'; return }
141
- $null = Invoke-TargetList @($targets | Where-Object { $_.Kind -ne 'cmd' })
142
- foreach ($e in $Script:WS_EDITORS) {
143
- $root = Join-Path $Script:P.A $e.Root
144
- $extRoot = Join-Path $Script:P.U "$($e.Ext)\extensions"
145
- if (-not (Test-DirPresent $root) -and -not (Test-DirPresent $extRoot)) { continue }
146
- if (Test-ProcessRunning $e.Proc) { Write-Warn "$($e.Name) is running - workspace storage and extension versions left alone"; continue }
147
- Remove-StaleWorkspaceStorage -Root (Join-Path $root 'User\workspaceStorage') -Label "$($e.Name) workspace storage"
148
- Remove-SupersededExtensions -Root $extRoot -Label "$($e.Name) extensions"
149
- }
150
- }
1
+ # editors.ps1 - section 6: editor caches, stale workspace storage, superseded extension versions.
2
+
3
+ $Script:WS_EDITORS = @(
4
+ @{ Name = 'VS Code'; Root = 'Code'; Ext = '.vscode'; Proc = @('Code') }
5
+ @{ Name = 'VS Code Insiders'; Root = 'Code - Insiders'; Ext = '.vscode-insiders'; Proc = @('Code - Insiders') }
6
+ @{ Name = 'VSCodium'; Root = 'VSCodium'; Ext = '.vscode-oss'; Proc = @('VSCodium') }
7
+ @{ Name = 'Cursor'; Root = 'Cursor'; Ext = '.cursor'; Proc = @('Cursor') }
8
+ @{ Name = 'Windsurf'; Root = 'Windsurf'; Ext = '.windsurf'; Proc = @('Windsurf') }
9
+ )
10
+
11
+ function Get-Targets06 {
12
+ $P = $Script:P
13
+ $t = @()
14
+ foreach ($e in $Script:WS_EDITORS) {
15
+ $root = Join-Path $P.A $e.Root
16
+ $t += (New-Target 6 "$($e.Name) caches" $root -Kind editor -Guard $e.Proc -Note 'Cache, CachedData, Code Cache, GPUCache, crash reports; skipped while the editor runs')
17
+ $t += (New-Target 6 "$($e.Name) VSIX download cache" (Join-Path $root 'CachedExtensionVSIXs') -Mode clear -Note 'extension installers already unpacked; safe while the editor runs')
18
+ $t += (New-Target 6 "$($e.Name) logs" (Join-Path $root 'logs') -Mode clear-old -Days 7)
19
+ $t += (New-Target 6 "$($e.Name) stale workspace storage" (Join-Path $root 'User\workspaceStorage') -Kind cmd -Guard $e.Proc -Note 'entries whose folder no longer exists')
20
+ $t += (New-Target 6 "$($e.Name) superseded extension versions" (Join-Path $P.U "$($e.Ext)\extensions") -Kind cmd -Guard $e.Proc -Note 'older versions and .obsolete entries; newest kept')
21
+ }
22
+ $t += (New-Target 6 'Visual Studio ComponentModelCache' "$($P.L)\Microsoft\VisualStudio\*\ComponentModelCache" -Kind glob -Mode clear -Guard @('devenv'))
23
+ $t += (New-Target 6 'Visual Studio designer shadow cache' "$($P.L)\Microsoft\VisualStudio\*\Designer\ShadowCache" -Kind glob -Mode clear -Guard @('devenv'))
24
+ $t += (New-Target 6 'Visual Studio telemetry (AppInsights)' "$($P.L)\Microsoft\VSApplicationInsights" -Mode clear)
25
+ $t += (New-Target 6 'Visual Studio SQM logs' "$($P.L)\Microsoft\VSCommon\*\SQM" -Kind glob -Mode clear)
26
+ return $t
27
+ }
28
+
29
+ function Remove-StaleWorkspaceStorage {
30
+ <# .SYNOPSIS workspaceStorage entries pointing at a folder/workspace file that no longer exists. #>
31
+ param([string] $Root, [string] $Label)
32
+ if (-not (Test-DirPresent $Root)) { return }
33
+ $stale = @()
34
+ foreach ($e in (Get-ChildEntries $Root)) {
35
+ if (-not ($e -is [IO.DirectoryInfo])) { continue }
36
+ $json = Join-Path (Remove-LongPrefix $e.FullName) 'workspace.json'
37
+ if (-not (Test-PathPresent $json)) { continue }
38
+ try {
39
+ $w = Get-Content -LiteralPath $json -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
40
+ $uri = $w.folder
41
+ if (-not $uri) { $uri = $w.workspace }
42
+ if (-not $uri -or $uri -notmatch '^file:///') { continue }
43
+ $local = [Uri]::UnescapeDataString(($uri -replace '^file:///', '')) -replace '/', '\'
44
+ if (-not (Test-PathPresent $local)) { $stale += [pscustomobject]@{ Path = (Remove-LongPrefix $e.FullName); Target = $local } }
45
+ } catch { $null = $_ }
46
+ }
47
+ if ($stale.Count -eq 0) { Write-Info "$Label - none stale"; return }
48
+ Write-Info "$Label - $($stale.Count) entries point at folders that no longer exist:"
49
+ foreach ($s in $stale) { Write-Note " $($s.Target)" }
50
+ foreach ($s in $stale) { $null = Remove-PathSafe -Path $s.Path -Within $Root -Label $Label }
51
+ if (-not $Script:WS.DryRun) { Write-Ok "$Label - removed $($stale.Count) stale entries" }
52
+ }
53
+
54
+ function Read-JsonFile {
55
+ <# .SYNOPSIS Parse a JSON file. A top-level array always comes back as an array (PowerShell 5.1's ConvertFrom-Json
56
+ emits an array as ONE pipeline object, which silently turns 16 entries into a single bogus one when wrapped). #>
57
+ param([string] $Path)
58
+ $parsed = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
59
+ if ($parsed -is [array]) { return $parsed }
60
+ return @($parsed)
61
+ }
62
+
63
+ function Get-SupersededExtensionPlan {
64
+ <# .SYNOPSIS Decide which extension folders are leftovers. Pure logic (exercised by the self-test).
65
+ A folder is a victim only when the editor's extensions.json does NOT reference it AND either the .obsolete marker
66
+ names it or a different version of the same extension IS referenced. Without extensions.json: .obsolete entries
67
+ plus every version older than the newest one present. #>
68
+ param([string] $Root, [object[]] $Folders)
69
+ $victims = @()
70
+ $obsolete = @{}
71
+ $obsFile = Join-Path $Root '.obsolete'
72
+ if (Test-PathPresent $obsFile) {
73
+ try { $o = Get-Content -LiteralPath $obsFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop; foreach ($p in $o.PSObject.Properties) { if ($p.Value) { $obsolete[$p.Name] = $true } } } catch { $null = $_ }
74
+ }
75
+ $installed = $null
76
+ $manifest = Join-Path $Root 'extensions.json'
77
+ if (Test-PathPresent $manifest) {
78
+ try {
79
+ $installed = @{}
80
+ foreach ($entry in (Read-JsonFile $manifest)) {
81
+ $loc = $entry.relativeLocation
82
+ if ($loc -is [string] -and $loc) { $installed[$loc] = $true }
83
+ }
84
+ } catch { $installed = $null }
85
+ }
86
+ if ($null -ne $installed -and $installed.Count -gt 0) {
87
+ $installedIds = @{}
88
+ foreach ($f in $Folders) { if ($installed.ContainsKey($f.Name)) { $installedIds[$f.Id] = $true } }
89
+ foreach ($f in $Folders) {
90
+ if ($installed.ContainsKey($f.Name)) { continue }
91
+ if ($obsolete.ContainsKey($f.Name) -or $installedIds.ContainsKey($f.Id)) { $victims += $f }
92
+ }
93
+ $note = "extensions.json lists $($installed.Count) installed extension(s); $($victims.Count) leftover folder(s)"
94
+ } else {
95
+ foreach ($group in @($Folders | Where-Object { -not $obsolete.ContainsKey($_.Name) } | Group-Object Id)) {
96
+ if ($group.Count -lt 2) { continue }
97
+ $newest = $group.Group | Sort-Object Version -Descending | Select-Object -First 1
98
+ foreach ($v in $group.Group) { if ($v.Path -ne $newest.Path) { $victims += $v } }
99
+ }
100
+ foreach ($f in $Folders) { if ($obsolete.ContainsKey($f.Name)) { $victims += $f } }
101
+ $note = "no extensions.json; using .obsolete + newest-version rule: $($victims.Count) leftover folder(s)"
102
+ }
103
+ return [pscustomobject]@{ Victims = @($victims); Note = $note }
104
+ }
105
+
106
+ function Remove-SupersededExtensions {
107
+ <# .SYNOPSIS Extension folders the editor no longer uses.
108
+ The editor's own extensions.json is the source of truth: every versioned folder it does not reference is a leftover
109
+ (uninstalled, or superseded by the version it does reference). Without that file, fall back to the .obsolete marker
110
+ plus keep-the-newest-version-per-extension. #>
111
+ param([string] $Root, [string] $Label)
112
+ if (-not (Test-DirPresent $Root)) { return }
113
+ $folders = @()
114
+ foreach ($e in @(Get-ChildEntries $Root)) {
115
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
116
+ if ($e.Name -match '^(.+?)-(\d+\.\d+\.\d+)([^\\]*)$') {
117
+ $ver = $null
118
+ try { $ver = [version]$Matches[2] } catch { continue }
119
+ $folders += [pscustomobject]@{ Name = $e.Name; Id = $Matches[1]; Path = (Remove-LongPrefix $e.FullName); Version = $ver }
120
+ }
121
+ }
122
+ if ($folders.Count -eq 0) { Write-Info "$Label - none superseded"; return }
123
+ $plan = Get-SupersededExtensionPlan -Root $Root -Folders $folders
124
+ Write-Note "$Label - $($plan.Note)"
125
+ $victims = @($plan.Victims)
126
+ if ($victims.Count -eq 0) { Write-Info "$Label - none superseded"; return }
127
+ foreach ($v in $victims) {
128
+ $r = Remove-PathSafe -Path $v.Path -Within $Root -Label $Label
129
+ if ($r.Removed -and -not $Script:WS.DryRun) { Write-Ok ("$Label - removed $($v.Name) (" + (Format-Bytes $r.Bytes) + ')') }
130
+ }
131
+ }
132
+
133
+ function Invoke-Section06 {
134
+ Write-SectionIntro @(
135
+ 'Editors rebuild their caches on the next start. Settings, keybindings, snippets, globalStorage and local',
136
+ 'History are protected. A running editor is left alone except for its VSIX download cache and old logs.'
137
+ )
138
+ $targets = Get-Targets06
139
+ $null = Show-TargetSizes $targets
140
+ if (-not (Confirm-Section 'Clean editor caches now?')) { Write-Info 'skipped'; return }
141
+ $null = Invoke-TargetList @($targets | Where-Object { $_.Kind -ne 'cmd' })
142
+ foreach ($e in $Script:WS_EDITORS) {
143
+ $root = Join-Path $Script:P.A $e.Root
144
+ $extRoot = Join-Path $Script:P.U "$($e.Ext)\extensions"
145
+ if (-not (Test-DirPresent $root) -and -not (Test-DirPresent $extRoot)) { continue }
146
+ if (Test-ProcessRunning $e.Proc) { Write-Warn "$($e.Name) is running - workspace storage and extension versions left alone"; continue }
147
+ Remove-StaleWorkspaceStorage -Root (Join-Path $root 'User\workspaceStorage') -Label "$($e.Name) workspace storage"
148
+ Remove-SupersededExtensions -Root $extRoot -Label "$($e.Name) extensions"
149
+ }
150
+ }