windowsweep 1.0.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.
@@ -0,0 +1,140 @@
1
+ # projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
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')
4
+ $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
+ $Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
6
+
7
+ function Get-ProjectScanRoots {
8
+ $ws = $Script:WS
9
+ $U = $Script:P.U
10
+ $roots = @()
11
+ if ($ws.ScanRoots.Count -gt 0) {
12
+ foreach ($r in $ws.ScanRoots) {
13
+ $f = Get-FullPath $r
14
+ if (-not $f -or -not (Test-DirPresent $f)) { Write-Warn "scan root not found: $r"; continue }
15
+ if ($f -match '^[A-Za-z]:\\?$' -or $f.Equals($U, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "refusing to scan a whole drive or your profile root: $f (name a projects folder)"; continue }
16
+ $roots += $f
17
+ }
18
+ return $roots
19
+ }
20
+ $names = @('source\repos', 'source', 'repos', 'Projects', 'projects', 'code', 'Code', 'dev', 'Dev', 'Development', 'work', 'Work', 'git', 'GitHub', 'Documents\GitHub', 'Documents\Projects', 'Documents\code', 'Documents\dev', 'Documents\repos')
21
+ foreach ($n in $names) { $p = Join-Path $U $n; if (Test-DirPresent $p) { $roots += (Get-FullPath $p) } }
22
+ foreach ($d in [IO.DriveInfo]::GetDrives()) {
23
+ if (-not $d.IsReady -or $d.DriveType -ne [IO.DriveType]::Fixed) { continue }
24
+ foreach ($n in @('work', 'code', 'projects', 'repos', 'dev', 'src', 'git', 'Projects', 'Code', 'Work')) {
25
+ $p = Join-Path $d.Name $n
26
+ if ((Test-DirPresent $p) -and ($roots -notcontains (Get-FullPath $p))) { $roots += (Get-FullPath $p) }
27
+ }
28
+ }
29
+ return @($roots | Select-Object -Unique)
30
+ }
31
+
32
+ function Get-Targets17 {
33
+ $roots = @(Get-ProjectScanRoots)
34
+ $t = @()
35
+ foreach ($r in $roots) { $t += (New-Target 17 'Project scan root' $r -Kind cmd -Note ($Script:WS_ARTEFACT_DIRS -join ', ')) }
36
+ if ($t.Count -eq 0) { $t += (New-Target 17 'Project scan root' '(none detected - pass --scan-roots "D:\work;E:\code")' -Kind cmd) }
37
+ return $t
38
+ }
39
+
40
+ function Test-ProjectMarker {
41
+ param([string] $Dir, [string] $ArtefactName)
42
+ $entries = Get-ChildEntries $Dir
43
+ $names = @($entries | ForEach-Object { $_.Name })
44
+ if ($ArtefactName -in 'bin', 'obj') { return [bool]($names | Where-Object { $_ -like '*.csproj' -or $_ -like '*.sln' -or $_ -like '*.fsproj' }) }
45
+ foreach ($m in $Script:WS_PROJECT_MARKERS) { if ($names -contains $m) { return $true } }
46
+ return ($names -contains '.git')
47
+ }
48
+
49
+ function Get-ProjectActivityDays {
50
+ <# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
51
+ param([string] $ProjectDir)
52
+ $newest = [datetime]::MinValue
53
+ $stack = New-Object System.Collections.Generic.Stack[string]
54
+ $stack.Push($ProjectDir)
55
+ $seen = 0
56
+ while ($stack.Count -gt 0 -and $seen -lt 200000) {
57
+ $dir = $stack.Pop()
58
+ foreach ($e in (Get-ChildEntries $dir)) {
59
+ $seen++
60
+ if (Test-ReparsePoint $e) { continue }
61
+ if ($e -is [IO.DirectoryInfo]) {
62
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name) { continue }
63
+ $stack.Push((Remove-LongPrefix $e.FullName)); continue
64
+ }
65
+ $t = Get-NewestTimestampUtc $e
66
+ if ($t -gt $newest) { $newest = $t }
67
+ }
68
+ }
69
+ if ($newest -eq [datetime]::MinValue) { return (Get-IdleDays $ProjectDir) }
70
+ return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
71
+ }
72
+
73
+ function Find-StaleArtefacts {
74
+ <# .SYNOPSIS Artefact folders (node_modules, dist, ...) whose project has been idle for N+ days. #>
75
+ param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 6)
76
+ $ws = $Script:WS
77
+ $found = New-Object System.Collections.Generic.List[object]
78
+ $projectAge = @{}
79
+ foreach ($root in $Roots) {
80
+ $stack = New-Object System.Collections.Generic.Stack[object]
81
+ $stack.Push(@($root, 0))
82
+ while ($stack.Count -gt 0) {
83
+ $item = $stack.Pop()
84
+ $dir = [string]$item[0]; $depth = [int]$item[1]
85
+ foreach ($e in (Get-ChildEntries $dir)) {
86
+ if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
87
+ $full = Remove-LongPrefix $e.FullName
88
+ $skip = $false
89
+ foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
90
+ if ($skip) { continue }
91
+ if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj')) {
92
+ if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
93
+ if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
94
+ $age = $projectAge[$dir]
95
+ if ($age -ge $Days) { $found.Add([pscustomobject]@{ Path = $full; Project = $dir; Age = $age; Bytes = [long](Get-DirectoryBytes $full); Root = $root }) }
96
+ continue
97
+ }
98
+ if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
99
+ if ($depth -lt $MaxDepth) { $stack.Push(@($full, $depth + 1)) }
100
+ }
101
+ }
102
+ }
103
+ return @($found | Sort-Object Bytes -Descending)
104
+ }
105
+
106
+ function Invoke-Section17 {
107
+ $ws = $Script:WS
108
+ Write-SectionIntro @(
109
+ 'node_modules, dist, .next, target, vendor and friends are rebuilt by the next install or build. Only projects',
110
+ "whose SOURCE files nobody touched for $($ws.Days)+ days are listed, and nothing goes without your selection.",
111
+ 'Toolchain folders (.gradle, .nuget, .cargo, AppData) and .git are never entered.'
112
+ )
113
+ $roots = @(Get-ProjectScanRoots)
114
+ if ($roots.Count -eq 0) { Write-Warn 'no project roots found. Pass --scan-roots "D:\work;E:\code" (semicolon-separated) or save scanRoots in config.json.'; return }
115
+ Write-Info ("scanning: " + ($roots -join ' '))
116
+ $found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
117
+ if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
118
+ $reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
119
+ Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
120
+ $i = 1; $total = [long]0
121
+ foreach ($f in $found) {
122
+ Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
123
+ $reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
124
+ $total += $f.Bytes; $i++
125
+ if ($i -gt 200) { Write-Warn 'list truncated to 200 entries'; break }
126
+ }
127
+ Write-Info ("total: " + (Format-Bytes $total))
128
+ if (-not $ws.NoReport) {
129
+ $out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
130
+ try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
131
+ }
132
+ $picks = Read-MultiSelect -Total ([math]::Min($found.Count, 200))
133
+ if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
134
+ if (-not (Confirm-Section "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" 'n')) { Write-Info 'skipped'; return }
135
+ foreach ($k in $picks) {
136
+ $f = $found[$k - 1]
137
+ $r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
138
+ if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
139
+ }
140
+ }
@@ -0,0 +1,414 @@
1
+ # release_helpers.ps1 - version, --list, --self-test, feedback, debug bundle, scheduled task, profile alias, data removal.
2
+
3
+ function Show-Version {
4
+ Write-Host ''
5
+ Write-Host (" $Script:WS_NAME v$(Get-ToolVersion)")
6
+ Write-Host (" $Script:WS_TAGLINE")
7
+ Write-Host ''
8
+ Write-Host (" Author: $Script:WS_AUTHOR")
9
+ Write-Host (" Email: $Script:WS_EMAIL")
10
+ Write-Host (" Web: $Script:WS_WEB")
11
+ Write-Host (" LinkedIn: $Script:WS_LINKEDIN")
12
+ Write-Host (" Source: $Script:WS_REPO")
13
+ Write-Host (" License: $Script:WS_LICENSE")
14
+ Write-Host ''
15
+ Write-Host (" PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))")
16
+ Write-Host (" Project root: $Script:WS_ROOT")
17
+ Write-Host ''
18
+ }
19
+
20
+ function Show-SectionList {
21
+ Write-Host ''
22
+ Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f '#', 'SECTION', 'TIER', 'ADMIN', 'BATCH')
23
+ foreach ($s in $Script:WS_SECTIONS) {
24
+ $adm = '-'
25
+ if ($s.Admin) { $adm = 'yes' }
26
+ Write-Host (" {0,3} {1,-84} {2,-10} {3,-5} {4}" -f $s.Id, $s.Title, $s.Tier, $adm, $s.Batch)
27
+ }
28
+ Write-Host ''
29
+ Write-Host (" safe batch (--all): " + ($Script:WS_SAFE_BATCH -join ',') + " (+" + ($Script:WS_SAFE_BATCH_ADMIN -join ',') + " when elevated)")
30
+ foreach ($k in ($Script:WS_PROFILES.Keys | Sort-Object)) { Write-Host (" profile {0,-11} {1}" -f $k, ($Script:WS_PROFILES[$k] -join ',')) }
31
+ Write-Host ' batch policy: safe = runs in --all | optin = --only/--profile with --yes | deep = also needs --i-understand-deep | interactive = never batch'
32
+ Write-Host ''
33
+ }
34
+
35
+ # ---------------------------------------------------------------------------------------------
36
+ # Self-test
37
+ # ---------------------------------------------------------------------------------------------
38
+
39
+ function Invoke-SelfTest {
40
+ <# .SYNOPSIS Verify syntax, ASCII-only source, safety guards, dry-run, junctions and long paths. Exit 1 on any failure. #>
41
+ $ws = $Script:WS
42
+ $fails = 0
43
+ $checks = 0
44
+ Write-Box 'Self-test' 'Verify syntax, safety guards, dry-run guarantee, junction handling'
45
+
46
+ Write-Section '[1] PowerShell'
47
+ $checks++
48
+ if ($PSVersionTable.PSVersion.Major -ge 5) { Write-Ok "PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))" } else { Write-Err "PowerShell $($PSVersionTable.PSVersion) - 5.1+ required"; $fails++ }
49
+
50
+ Write-Section '[2] Optional tools'
51
+ foreach ($c in @('robocopy.exe', 'cleanmgr.exe', 'Dism.exe', 'powercfg.exe', 'wevtutil.exe', 'diskpart.exe', 'wsl.exe', 'docker', 'npm', 'yarn', 'pnpm', 'pip', 'uv', 'composer', 'dotnet', 'cargo', 'go')) {
52
+ if (Test-CommandPresent $c) { Write-Ok $c } else { Write-Note "$c (optional - the steps that use it are skipped)" }
53
+ }
54
+
55
+ Write-Section '[3] Script syntax'
56
+ $files = @(Get-Item -LiteralPath $ws.ScriptPath) + @(Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'lib'), (Join-Path $Script:WS_ROOT 'modules') -Filter '*.ps1' -File)
57
+ foreach ($f in $files) {
58
+ $checks++
59
+ $tokens = $null; $errors = $null
60
+ $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$tokens, [ref]$errors)
61
+ if ($errors.Count -eq 0) { Write-Ok $f.Name } else { Write-Err "$($f.Name): $($errors[0].Message)"; $fails++ }
62
+ }
63
+
64
+ Write-Section '[4] ASCII-only source'
65
+ $srcFiles = @($files) + @(Get-ChildItem -LiteralPath (Join-Path $Script:WS_ROOT 'bin') -Filter '*.js' -File -ErrorAction SilentlyContinue)
66
+ foreach ($f in $srcFiles) {
67
+ $checks++
68
+ $bad = 0
69
+ foreach ($b in [IO.File]::ReadAllBytes($f.FullName)) { if ($b -gt 127) { $bad++; break } }
70
+ if ($bad -eq 0) { Write-Ok $f.Name } else { Write-Err "$($f.Name): $bad non-ASCII byte(s) - PowerShell 5.1 would misread this file"; $fails++ }
71
+ }
72
+
73
+ Write-Section '[5] Safety guards'
74
+ $U = $env:USERPROFILE; $L = $env:LOCALAPPDATA
75
+ $mustRefuse = @("$env:SystemDrive\", $env:SystemRoot, "$env:SystemRoot\System32", $env:ProgramFiles, $U, "$U\Documents", "$U\Pictures\holiday", "$U\.ssh\id_ed25519", "$U\.claude\settings.json", "$env:APPDATA\npm", "$env:SystemRoot\Prefetch", "$env:SystemRoot\Installer", "$L\Temp\..\..\Roaming", '\\server\share\x', "$L\Google\Chrome\User Data\Default\Local Storage\leveldb", "$L\Google\Chrome\User Data\Default", "$L\Google\Chrome\User Data\Profile 12", "$L\Packages\Some.App_abc\LocalState\data", "$env:APPDATA\Code\User\globalStorage\state.vscdb", "$U\.cache\claude\x", "$env:SystemDrive\hiberfil.sys", $ws.Home)
76
+ foreach ($p in $mustRefuse) {
77
+ $checks++
78
+ $why = Get-ProtectionReason $p
79
+ if ($why) { Write-Ok "refuses $p ($why)" } else { Write-Err "DANGEROUS: not protected: $p"; $fails++ }
80
+ }
81
+ $mustAllow = @("$L\npm-cache\_cacache", "$L\Temp\some-tmp-file.tmp", "$L\Google\Chrome\User Data\Default\Cache\Cache_Data", "$L\Yarn\Cache\v6", "$U\.gradle\caches\modules-2", "$L\Android\Sdk\.temp\x", "$env:APPDATA\Code\CachedExtensionVSIXs", "$U\.vscode\extensions\some.ext-1.0.0")
82
+ foreach ($p in $mustAllow) {
83
+ $checks++
84
+ $why = Get-ProtectionReason $p
85
+ if (-not $why) { Write-Ok "allows $p" } else { Write-Err "would block a legitimate target: $p ($why)"; $fails++ }
86
+ }
87
+ $checks++
88
+ if ((Test-PathWithin -Path "$L\Temp\a\b" -Within "$L\Temp") -and -not (Test-PathWithin -Path "$L\Tempx\a" -Within "$L\Temp") -and (Test-PathWithin -Path "$L\Temp" -Within "$L\Temp")) { Write-Ok 'Test-PathWithin: inside / sibling-prefix / equal' } else { Write-Err 'Test-PathWithin is wrong'; $fails++ }
89
+
90
+ Write-Section '[6] Declared targets are never protected'
91
+ $targets = @(Get-AllTargets | Where-Object { $_.Kind -in 'dir', 'file' })
92
+ $checks++
93
+ $leaked = 0
94
+ foreach ($t in $targets) {
95
+ $why = Get-ProtectionReason $t.Path
96
+ if ($why) { Write-Err "target [$($t.Section)] $($t.Label) is inside a protected path: $($t.Path) ($why)"; $leaked++ }
97
+ }
98
+ if ($leaked -eq 0) { Write-Ok "$($targets.Count) declared targets, none inside a protected path" } else { $fails += $leaked }
99
+
100
+ Write-Section '[7] Fixture: junctions, dry-run, stale prune, keep-newest, long paths'
101
+ $fx = Join-Path $ws.Home ('selftest-' + [guid]::NewGuid().ToString('N'))
102
+ $savedDry = $ws.DryRun; $savedYes = $ws.Yes; $savedQuiet = $ws.Quiet
103
+ # Helper output (including the refusals we deliberately provoke) is muted; only the verdict lines print.
104
+ $mute = { $Script:WS.Mute = $true }
105
+ $unmute = { $Script:WS.Mute = $false }
106
+ try {
107
+ New-Item -ItemType Directory -Force -Path $fx | Out-Null
108
+ $ws.AllowOwnData = $true
109
+ & $mute
110
+ $target = Join-Path $fx 'target'; New-Item -ItemType Directory -Force -Path $target | Out-Null
111
+ $sentinel = Join-Path $target 'sentinel.txt'; Set-Content -LiteralPath $sentinel -Value 'keep me'
112
+ $cache = Join-Path $fx 'cache'; New-Item -ItemType Directory -Force -Path $cache | Out-Null
113
+ New-Item -ItemType Junction -Path (Join-Path $cache 'link') -Target $target | Out-Null
114
+ Set-Content -LiteralPath (Join-Path $cache 'junk.txt') -Value 'junk'
115
+ # 7a: removing a junction removes the link only
116
+ $checks++
117
+ $ws.DryRun = $false
118
+ $r = Remove-PathSafe -Path (Join-Path $cache 'link') -Within $cache
119
+ & $unmute
120
+ if ($r.Removed -and (Test-Path -LiteralPath $sentinel) -and -not (Test-Path -LiteralPath (Join-Path $cache 'link'))) { Write-Ok 'junction removed as a link; target sentinel survives' } else { Write-Err 'junction handling is wrong'; $fails++ }
121
+ & $mute
122
+ # 7b: a junction nested inside a tree being removed is not followed
123
+ $checks++
124
+ $tree = Join-Path $fx 'tree'; New-Item -ItemType Directory -Force -Path (Join-Path $tree 'sub') | Out-Null
125
+ New-Item -ItemType Junction -Path (Join-Path $tree 'sub\jl') -Target $target | Out-Null
126
+ Set-Content -LiteralPath (Join-Path $tree 'sub\file.txt') -Value 'x'
127
+ $r = Remove-PathSafe -Path $tree -Within $fx
128
+ & $unmute
129
+ if ($r.Removed -and (Test-Path -LiteralPath $sentinel) -and -not (Test-Path -LiteralPath $tree)) { Write-Ok 'nested junction not followed; tree removed; sentinel survives' } else { Write-Err 'nested junction handling is wrong'; $fails++ }
130
+ & $mute
131
+ # 7c: dry-run changes nothing
132
+ $checks++
133
+ $old = Join-Path $fx 'old'; New-Item -ItemType Directory -Force -Path (Join-Path $old 'deep') | Out-Null
134
+ $oldFile = Join-Path $old 'deep\old.bin'; [IO.File]::WriteAllBytes($oldFile, (New-Object byte[] 4096))
135
+ $freshFile = Join-Path $old 'fresh.bin'; [IO.File]::WriteAllBytes($freshFile, (New-Object byte[] 2048))
136
+ $ancient = (Get-Date).AddDays(-400)
137
+ foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath $oldFile -Name $ts -Value $ancient }
138
+ # Files carry the evidence (name, size, write time); directory timestamps are excluded because NTFS
139
+ # updates a folder's own LastWriteTime lazily, which made an earlier version of this check flap.
140
+ $snapshot = { param($root) ((Get-ChildItem -LiteralPath $root -Recurse -Force -File | ForEach-Object { "$($_.FullName)|$($_.Length)|$($_.LastWriteTimeUtc.Ticks)" }) + (Get-ChildItem -LiteralPath $root -Recurse -Force -Directory | ForEach-Object { "D|$($_.FullName)" })) -join "`n" }
141
+ $snapBefore = & $snapshot $old
142
+ $ws.DryRun = $true
143
+ $estBefore = $ws.TotalEstimated
144
+ $r = Remove-StaleFiles -Root $old -Within $fx -Days 100 -Label 'fixture'
145
+ $snapAfter = & $snapshot $old
146
+ $ws.DryRun = $false
147
+ & $unmute
148
+ if ($snapBefore -eq $snapAfter -and $r.Freed -eq 4096 -and ($ws.TotalEstimated - $estBefore) -eq 4096) { Write-Ok 'dry-run changed nothing and estimated exactly the stale 4096 bytes' } else { Write-Err "dry-run guarantee broken (freed=$($r.Freed), tree changed=$($snapBefore -ne $snapAfter))"; $fails++ }
149
+ & $mute
150
+ # 7d: real stale prune removes only the old file and sweeps the empty dir
151
+ $checks++
152
+ $r = Remove-StaleFiles -Root $old -Within $fx -Days 100 -Label 'fixture'
153
+ & $unmute
154
+ if ((-not (Test-Path -LiteralPath $oldFile)) -and (Test-Path -LiteralPath $freshFile) -and (-not (Test-Path -LiteralPath (Join-Path $old 'deep'))) -and $r.Freed -eq 4096) { Write-Ok 'stale prune removed the idle file, kept the fresh one, swept the empty directory' } else { Write-Err 'stale prune behaved wrongly'; $fails++ }
155
+ & $mute
156
+ # 7e: keep-newest keeps the freshest unit even when every unit is idle
157
+ $checks++
158
+ $units = Join-Path $fx 'units'
159
+ foreach ($v in '1.0.0', '2.0.0') { New-Item -ItemType Directory -Force -Path (Join-Path $units $v) | Out-Null; Set-Content -LiteralPath (Join-Path $units "$v\bin.txt") -Value $v }
160
+ $t1 = (Get-Date).AddDays(-300); $t2 = (Get-Date).AddDays(-200)
161
+ foreach ($ts in 'LastWriteTime', 'LastAccessTime', 'CreationTime') { Set-ItemProperty -LiteralPath (Join-Path $units '1.0.0\bin.txt') -Name $ts -Value $t1; Set-ItemProperty -LiteralPath (Join-Path $units '2.0.0\bin.txt') -Name $ts -Value $t2 }
162
+ $r = Remove-StaleUnits -Root $units -Within $fx -Days 100 -KeepNewest -DirectoriesOnly -Label 'fixture units'
163
+ & $unmute
164
+ if ((Test-Path -LiteralPath (Join-Path $units '2.0.0')) -and -not (Test-Path -LiteralPath (Join-Path $units '1.0.0')) -and $r.Pruned -eq 1 -and $r.Kept -eq 1) { Write-Ok 'keep-newest kept 2.0.0 (200d idle) and pruned 1.0.0 (300d idle)' } else { Write-Err 'keep-newest behaved wrongly'; $fails++ }
165
+ & $mute
166
+ # 7f: long path
167
+ $checks++
168
+ $deep = Join-Path $fx 'long'; $p = $deep
169
+ for ($i = 0; $i -lt 12; $i++) { $p = "$p\segment_$('x' * 20)_$i" }
170
+ [IO.Directory]::CreateDirectory((Get-LongPath $p)) | Out-Null
171
+ [IO.File]::WriteAllText((Get-LongPath (Join-Path $p 'deep.txt')), 'x')
172
+ $r = Remove-PathSafe -Path $deep -Within $fx
173
+ & $unmute
174
+ if ($r.Removed -and -not (Test-DirPresent $deep) -and $p.Length -gt 260) { Write-Ok "long path ($($p.Length) chars) removed" } else { Write-Err 'long-path removal failed'; $fails++ }
175
+ & $mute
176
+ # 7g: refusals inside the fixture (the refusal messages themselves are muted; the verdicts print)
177
+ $checks++
178
+ $r = Remove-PathSafe -Path (Join-Path $fx 'target') -Within (Join-Path $fx 'cache')
179
+ & $unmute
180
+ if (-not $r.Removed -and $r.Reason -like 'outside declared root*' -and (Test-Path -LiteralPath $sentinel)) { Write-Ok 'a path outside its declared root is refused' } else { Write-Err 'Within guard failed'; $fails++ }
181
+ & $mute
182
+ $checks++
183
+ $r = Remove-PathSafe -Path $fx -Within $fx
184
+ & $unmute
185
+ if (-not $r.Removed -and (Test-Path -LiteralPath $fx)) { Write-Ok 'a root never deletes itself' } else { Write-Err 'root self-delete guard failed'; $fails++ }
186
+ $checks++
187
+ & $mute
188
+ $r = Remove-PathSafe -Path (Join-Path $env:USERPROFILE 'Documents') -Within $env:USERPROFILE
189
+ & $unmute
190
+ if (-not $r.Removed -and $r.Reason -like 'inside protected*') { Write-Ok 'the chokepoint refuses Documents even when a caller declares the profile as its root' } else { Write-Err 'protected-path refusal failed at the chokepoint'; $fails++ }
191
+ # 7h: editor extension leftovers - the manifest is an ARRAY (the ConvertFrom-Json shape that once collapsed 16 entries into 1)
192
+ $checks++
193
+ $extRoot = Join-Path $fx 'extensions'
194
+ foreach ($d in 'pub.alpha-1.0.0', 'pub.alpha-0.9.0', 'pub.beta-2.0.0-win32-x64', 'pub.gamma-1.0.0', 'pub.delta-3.0.0') { New-Item -ItemType Directory -Force -Path (Join-Path $extRoot $d) | Out-Null; Set-Content -LiteralPath (Join-Path $extRoot "$d\package.json") -Value '{}' }
195
+ Set-Content -LiteralPath (Join-Path $extRoot 'extensions.json') -Value '[{"identifier":{"id":"pub.alpha"},"version":"1.0.0","relativeLocation":"pub.alpha-1.0.0"},{"identifier":{"id":"pub.beta"},"version":"2.0.0","relativeLocation":"pub.beta-2.0.0-win32-x64"},{"identifier":{"id":"pub.delta"},"version":"3.0.0","relativeLocation":"pub.delta-3.0.0"}]'
196
+ Set-Content -LiteralPath (Join-Path $extRoot '.obsolete') -Value '{"pub.gamma-1.0.0":true}'
197
+ $folders = @()
198
+ foreach ($e in @(Get-ChildEntries $extRoot)) { if (($e -is [IO.DirectoryInfo]) -and $e.Name -match '^(.+?)-(\d+\.\d+\.\d+)([^\\]*)$') { $folders += [pscustomobject]@{ Name = $e.Name; Id = $Matches[1]; Path = (Remove-LongPrefix $e.FullName); Version = [version]$Matches[2] } } }
199
+ $plan = Get-SupersededExtensionPlan -Root $extRoot -Folders $folders
200
+ $names = @($plan.Victims | ForEach-Object { $_.Name } | Sort-Object)
201
+ if (($names -join ',') -eq 'pub.alpha-0.9.0,pub.gamma-1.0.0') { Write-Ok 'extension leftovers: superseded alpha-0.9.0 + obsolete gamma go; the 3 installed folders stay' } else { Write-Err "extension leftover plan wrong: [$($names -join ',')]"; $fails++ }
202
+ } catch {
203
+ & $unmute
204
+ Write-Err "fixture test crashed: $($_.Exception.Message)"; $fails++
205
+ } finally {
206
+ & $unmute
207
+ $ws.DryRun = $savedDry; $ws.Yes = $savedYes; $ws.Quiet = $savedQuiet
208
+ try { if (Test-Path -LiteralPath $fx) { [IO.Directory]::Delete((Get-LongPath $fx), $true) } } catch { $null = $_ }
209
+ $ws.AllowOwnData = $false
210
+ }
211
+
212
+ Write-Section '[8] Pure helpers'
213
+ $checks++
214
+ $sel = ConvertTo-IndexList -Text '1,3,5-7,9-8,99' -Total 10
215
+ if (($sel -join ',') -eq '1,3,5,6,7,8,9') { Write-Ok "multi-select parser: '1,3,5-7,9-8,99' -> $($sel -join ',')" } else { Write-Err "multi-select parser wrong: $($sel -join ',')"; $fails++ }
216
+ $checks++
217
+ if ((Format-Bytes 0) -eq '0 B' -and (Format-Bytes 1536) -eq '1.5 KB' -and (Format-Bytes 1073741824) -eq '1.0 GB') { Write-Ok 'Format-Bytes' } else { Write-Err "Format-Bytes wrong: $(Format-Bytes 1536)"; $fails++ }
218
+
219
+ Write-Section '[9] No network code'
220
+ $checks++
221
+ $needles = @(('Invoke-Web' + 'Request'), ('Invoke-Rest' + 'Method'), ('Net.Web' + 'Client'), ('Http' + 'Client'), ('Sockets.' + 'TcpClient'), ('curl' + '.exe'), ('wget' + ' '))
222
+ $hits = 0
223
+ foreach ($f in $srcFiles) {
224
+ $text = [IO.File]::ReadAllText($f.FullName)
225
+ foreach ($n in $needles) { if ($text.IndexOf($n, [StringComparison]::OrdinalIgnoreCase) -ge 0) { Write-Err "network call in $($f.Name): $n"; $hits++ } }
226
+ }
227
+ if ($hits -eq 0) { Write-Ok 'no HTTP or socket calls in the source' } else { $fails += $hits }
228
+
229
+ Write-Section '[10] Version parity'
230
+ $checks++
231
+ $verFile = Join-Path $Script:WS_ROOT 'VERSION'
232
+ $pkgFile = Join-Path $Script:WS_ROOT 'package.json'
233
+ $vFile = ''; $vPkg = ''
234
+ if (Test-Path -LiteralPath $verFile) { $vFile = (Get-Content -LiteralPath $verFile -Raw).Trim() }
235
+ if (Test-Path -LiteralPath $pkgFile) { try { $vPkg = (Get-Content -LiteralPath $pkgFile -Raw | ConvertFrom-Json).version } catch { $vPkg = '?' } }
236
+ if ($vFile -eq $Script:WS_VERSION_FALLBACK -and $vPkg -eq $Script:WS_VERSION_FALLBACK) { Write-Ok "VERSION, package.json and lib/constants.ps1 all say $vPkg" } else { Write-Err "version mismatch: VERSION='$vFile' package.json='$vPkg' constants='$Script:WS_VERSION_FALLBACK'"; $fails++ }
237
+
238
+ Write-Section '[11] Output paths'
239
+ foreach ($d in @($ws.LogsDir, $ws.ReportsDir)) {
240
+ $checks++
241
+ 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
+ }
243
+
244
+ Write-Separator
245
+ if ($fails -eq 0) { Write-Ok "all $checks checks passed - $Script:WS_NAME is ready"; return $Script:WS_EXIT_OK }
246
+ Write-Err "$fails of $checks checks FAILED"
247
+ return $Script:WS_EXIT_FAIL
248
+ }
249
+
250
+ # ---------------------------------------------------------------------------------------------
251
+ # Feedback, bug reports, debug bundle
252
+ # ---------------------------------------------------------------------------------------------
253
+
254
+ function Show-Feedback {
255
+ Write-Box 'Send feedback / report a bug' 'All offline - nothing leaves your machine unless you send it'
256
+ Write-Plain " Issues: $Script:WS_ISSUES"
257
+ Write-Plain " Email: $Script:WS_EMAIL"
258
+ Write-Plain " Web: $Script:WS_WEB"
259
+ Write-UiLine '' 'Gray'
260
+ Write-Plain ' Please include: Windows version, PowerShell version ($PSVersionTable.PSVersion),'
261
+ Write-Plain " $Script:WS_NAME version (currently v$(Get-ToolVersion)), the exact command, what you expected,"
262
+ Write-Plain ' what happened, and the log from --debug-bundle (review it first: it holds paths from your machine).'
263
+ Write-UiLine '' 'Gray'
264
+ Write-Plain ' windowsweep --report-issue opens a pre-filled GitHub issue in your browser'
265
+ Write-Plain ' windowsweep --debug-bundle zips the latest log + report for attaching'
266
+ Write-UiLine '' 'Gray'
267
+ Write-Note 'Privacy: windowsweep makes no network calls. Logs and reports are written only under ~\.windowsweep.'
268
+ }
269
+
270
+ function Invoke-ReportIssue {
271
+ <# .SYNOPSIS Build a GitHub new-issue URL with environment facts and open it in the default browser after confirming. #>
272
+ $ws = $Script:WS
273
+ $title = "[bug] $Script:WS_NAME v$(Get-ToolVersion): "
274
+ $body = @(
275
+ '**What happened**', '', '', '**What you expected**', '', '', '**Command you ran**', '', '```', 'windowsweep ', '```', '',
276
+ '**Environment (auto-filled, review before submitting)**',
277
+ "- OS: $($ws.OsCaption) ($([Environment]::OSVersion.Version))",
278
+ "- PowerShell: $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))",
279
+ "- $Script:WS_NAME`: v$(Get-ToolVersion) via $($ws.Launcher)",
280
+ "- Elevated: $($ws.IsAdmin)", '',
281
+ '**Log excerpt** (from `windowsweep --debug-bundle`, paths redacted as you see fit)', '', '```', '', '```'
282
+ ) -join "`n"
283
+ $url = "$Script:WS_ISSUES/new?title=$([Uri]::EscapeDataString($title))&labels=bug&body=$([Uri]::EscapeDataString($body))"
284
+ Write-Box 'Report an issue' 'A pre-filled GitHub issue opens in your browser; nothing is submitted until you click Submit'
285
+ Write-Note 'Environment facts included: OS build, PowerShell version, tool version, launcher, elevation. No paths, no logs.'
286
+ if (Confirm-Ui -Prompt 'Open the pre-filled issue in your browser now?' -Default 'y') {
287
+ try { Start-Process $url | Out-Null; Write-Ok 'opened - review, edit, then submit' } catch { Write-Warn "could not open a browser. URL:"; Write-Plain $url }
288
+ } else {
289
+ Write-Plain ' URL (copy it into a browser):'
290
+ Write-Plain " $url"
291
+ }
292
+ }
293
+
294
+ function New-DebugBundle {
295
+ Write-Box 'Debug bundle' 'Latest log + latest report + system manifest, zipped locally'
296
+ $zip = New-BundleZip -Prefix 'debug-bundle' -Manifest (New-SystemManifest -Kind 'debug bundle')
297
+ if ($zip) {
298
+ Write-Ok "bundle created: $zip"
299
+ Write-Warn 'Review the bundle before sharing - it contains paths from your machine and a cache-size snapshot.'
300
+ Write-Note "Attach it to an issue at $Script:WS_ISSUES"
301
+ }
302
+ }
303
+
304
+ # ---------------------------------------------------------------------------------------------
305
+ # Scheduled task, profile alias, data removal, history pruning
306
+ # ---------------------------------------------------------------------------------------------
307
+
308
+ $Script:WS_TASK_NAME = 'windowsweep weekly safe cleanup'
309
+
310
+ function Get-LaunchCommand {
311
+ <# .SYNOPSIS The command the scheduled task / alias should run: the launcher that started us, or the script. #>
312
+ $ws = $Script:WS
313
+ if ($ws.Launcher -eq 'node' -and -not $ws.Npx) {
314
+ $cmd = Get-Command windowsweep -ErrorAction SilentlyContinue
315
+ if ($cmd) { return [pscustomobject]@{ Exe = $cmd.Source; Args = @() } }
316
+ }
317
+ return [pscustomobject]@{ Exe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; Args = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', "`"$($ws.ScriptPath)`"") }
318
+ }
319
+
320
+ function Install-WeeklyTask {
321
+ Write-Box 'Install weekly Scheduled Task' "$Script:WS_TASK_NAME - Sundays 03:00, safe batch, no prompts"
322
+ if (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue) { Write-Info 'task already exists'; return }
323
+ $lc = Get-LaunchCommand
324
+ $argLine = (@($lc.Args) + @('--all', '--yes', '--quiet', '--no-color')) -join ' '
325
+ Write-Info "action: $($lc.Exe) $argLine"
326
+ if ($Script:WS.DryRun) { Write-DryRun 'would register the task'; return }
327
+ if (-not (Confirm-Ui -Prompt 'Register this task for your user account?' -Default 'y')) { Write-Info 'skipped'; return }
328
+ try {
329
+ $action = New-ScheduledTaskAction -Execute $lc.Exe -Argument $argLine
330
+ $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 3am
331
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -RunOnlyIfIdle:$false -ExecutionTimeLimit (New-TimeSpan -Hours 3)
332
+ Register-ScheduledTask -TaskName $Script:WS_TASK_NAME -Action $action -Trigger $trigger -Settings $settings -Description "$Script:WS_NAME safe batch (--all --yes). $Script:WS_REPO" -ErrorAction Stop | Out-Null
333
+ Write-Ok "registered '$Script:WS_TASK_NAME' (runs as your user, Sundays 03:00; catches up if the PC was off)"
334
+ } catch { Write-Err "could not register the task: $($_.Exception.Message)" }
335
+ }
336
+
337
+ function Uninstall-WeeklyTask {
338
+ Write-Box 'Remove weekly Scheduled Task'
339
+ if (-not (Get-ScheduledTask -TaskName $Script:WS_TASK_NAME -ErrorAction SilentlyContinue)) { Write-Info 'no task found'; return }
340
+ if ($Script:WS.DryRun) { Write-DryRun 'would unregister the task'; return }
341
+ if (Confirm-Ui -Prompt "Remove '$Script:WS_TASK_NAME'?" -Default 'y') {
342
+ try { Unregister-ScheduledTask -TaskName $Script:WS_TASK_NAME -Confirm:$false -ErrorAction Stop; Write-Ok 'task removed' } catch { Write-Err "could not remove the task: $($_.Exception.Message)" }
343
+ }
344
+ }
345
+
346
+ $Script:WS_ALIAS_MARK = '# windowsweep alias'
347
+
348
+ function Install-ProfileAlias {
349
+ Write-Box 'Install profile alias' "Adds a 'cleanup' function to your PowerShell profile"
350
+ $profilePath = $PROFILE.CurrentUserAllHosts
351
+ $lc = Get-LaunchCommand
352
+ $line = "function cleanup { & `"$($lc.Exe)`" $($lc.Args -join ' ') @args }"
353
+ if ((Test-Path -LiteralPath $profilePath) -and (Select-String -LiteralPath $profilePath -Pattern ([regex]::Escape($Script:WS_ALIAS_MARK)) -Quiet)) { Write-Info "alias already present in $profilePath"; return }
354
+ Write-Info "will append to $profilePath"
355
+ Write-Note $line
356
+ if ($Script:WS.DryRun) { Write-DryRun 'would append the alias'; return }
357
+ if (-not (Confirm-Ui -Prompt 'Add it?' -Default 'y')) { Write-Info 'skipped'; return }
358
+ try {
359
+ $dir = Split-Path -Parent $profilePath
360
+ if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
361
+ Add-Content -LiteralPath $profilePath -Value @('', $Script:WS_ALIAS_MARK, $line)
362
+ Write-Ok "added. Open a new PowerShell window (or run '. `$PROFILE.CurrentUserAllHosts'), then type: cleanup"
363
+ } catch { Write-Err "could not write the profile: $($_.Exception.Message)" }
364
+ }
365
+
366
+ function Uninstall-ProfileAlias {
367
+ Write-Box 'Remove profile alias'
368
+ $profilePath = $PROFILE.CurrentUserAllHosts
369
+ if (-not (Test-Path -LiteralPath $profilePath)) { Write-Info 'no profile file'; return }
370
+ $lines = Get-Content -LiteralPath $profilePath
371
+ if (-not ($lines | Where-Object { $_ -eq $Script:WS_ALIAS_MARK })) { Write-Info 'alias not present'; return }
372
+ if ($Script:WS.DryRun) { Write-DryRun 'would remove the alias block'; return }
373
+ if (-not (Confirm-Ui -Prompt "Remove the windowsweep alias from $profilePath ?" -Default 'y')) { Write-Info 'skipped'; return }
374
+ $out = @(); $skipNext = $false
375
+ foreach ($l in $lines) {
376
+ if ($l -eq $Script:WS_ALIAS_MARK) { $skipNext = $true; continue }
377
+ if ($skipNext) { $skipNext = $false; if ($l -like 'function cleanup*') { continue } }
378
+ $out += $l
379
+ }
380
+ Set-Content -LiteralPath $profilePath -Value $out
381
+ Write-Ok 'alias removed'
382
+ }
383
+
384
+ function Remove-ToolData {
385
+ $ws = $Script:WS
386
+ Write-Box 'Remove windowsweep data' $ws.Home
387
+ if (-not (Test-Path -LiteralPath $ws.Home)) { Write-Info 'nothing to remove'; return }
388
+ $bytes = Get-DirectoryBytes $ws.Home
389
+ Write-Info ("$($ws.Home) holds " + (Format-Bytes $bytes) + ' of logs, reports, bundles and config')
390
+ 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 }
392
+ $ws.LogFile = $null
393
+ try { [IO.Directory]::Delete((Get-LongPath $ws.Home), $true); Write-Ok 'removed' } catch { Write-Err "could not remove: $($_.Exception.Message)" }
394
+ }
395
+
396
+ function Remove-OldHistory {
397
+ <# .SYNOPSIS --prune-history N: delete logs, reports and bundles older than N days. Honours --dry-run. #>
398
+ param([int] $Days = 90)
399
+ $ws = $Script:WS
400
+ Write-Box "Prune run history older than $Days days" $ws.Home
401
+ $ws.AllowOwnData = $true
402
+ $n = 0; $bytes = [long]0
403
+ foreach ($dir in @($ws.LogsDir, $ws.ReportsDir, $ws.FeedbackDir)) {
404
+ if (-not (Test-Path -LiteralPath $dir)) { continue }
405
+ foreach ($f in (Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue)) {
406
+ if ($f.FullName -eq $ws.LogFile) { continue }
407
+ if ((Get-IdleDays $f.FullName) -lt $Days) { continue }
408
+ $r = Remove-PathSafe -Path $f.FullName -Within $dir
409
+ if ($r.Removed) { $n++; $bytes += $r.Bytes }
410
+ }
411
+ }
412
+ $ws.AllowOwnData = $false
413
+ if ($ws.DryRun) { Write-Info ("would remove $n file(s), " + (Format-Bytes $bytes)) } else { Write-Ok ("removed $n file(s), " + (Format-Bytes $bytes)) }
414
+ }