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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +482 -0
- package/SECURITY.md +46 -0
- package/VERSION +1 -0
- package/bin/windowsweep.js +83 -0
- package/lib/actions.ps1 +209 -0
- package/lib/config.ps1 +90 -0
- package/lib/constants.ps1 +89 -0
- package/lib/fs.ps1 +180 -0
- package/lib/log.ps1 +140 -0
- package/lib/safety.ps1 +493 -0
- package/lib/scan.ps1 +94 -0
- package/lib/ui.ps1 +232 -0
- package/modules/android_avd.ps1 +36 -0
- package/modules/app_caches.ps1 +58 -0
- package/modules/browsers.ps1 +33 -0
- package/modules/build_tools.ps1 +35 -0
- package/modules/crash_trap.ps1 +89 -0
- package/modules/disk_usage.ps1 +49 -0
- package/modules/docker.ps1 +93 -0
- package/modules/editors.ps1 +149 -0
- package/modules/health.ps1 +101 -0
- package/modules/menu.ps1 +61 -0
- package/modules/personal.ps1 +124 -0
- package/modules/pkg_managers.ps1 +69 -0
- package/modules/projects.ps1 +140 -0
- package/modules/release_helpers.ps1 +414 -0
- package/modules/reports.ps1 +212 -0
- package/modules/runner.ps1 +190 -0
- package/modules/system_admin.ps1 +220 -0
- package/modules/test_runners.ps1 +24 -0
- package/modules/walkthrough.ps1 +79 -0
- package/modules/windows_user.ps1 +112 -0
- package/package.json +73 -0
- package/windowsweep.cmd +6 -0
- package/windowsweep.ps1 +292 -0
|
@@ -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
|
+
}
|