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
package/lib/log.ps1
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# log.ps1 - session log, freed-bytes tally, drive snapshots and the JSON session report.
|
|
2
|
+
|
|
3
|
+
function Initialize-Log {
|
|
4
|
+
<# .SYNOPSIS Create the data directories and open this run's log with a credit header. #>
|
|
5
|
+
$ws = $Script:WS
|
|
6
|
+
foreach ($d in @($ws.LogsDir, $ws.ReportsDir)) {
|
|
7
|
+
if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }
|
|
8
|
+
}
|
|
9
|
+
$ws.LogFile = Join-Path $ws.LogsDir ("$Script:WS_NAME-" + $ws.Stamp + '.log')
|
|
10
|
+
$elev = 'no'
|
|
11
|
+
if ($ws.IsAdmin) { $elev = 'yes' }
|
|
12
|
+
$dry = 'no'
|
|
13
|
+
if ($ws.DryRun) { $dry = 'yes' }
|
|
14
|
+
$header = @(
|
|
15
|
+
'# ============================================================'
|
|
16
|
+
"# $Script:WS_NAME v$(Get-ToolVersion) - session log"
|
|
17
|
+
"# Author: $Script:WS_AUTHOR <$Script:WS_EMAIL>"
|
|
18
|
+
"# Web: $Script:WS_WEB"
|
|
19
|
+
"# License: $Script:WS_LICENSE"
|
|
20
|
+
"# Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
|
|
21
|
+
"# Mode: $($ws.Mode)"
|
|
22
|
+
"# Host: $env:COMPUTERNAME"
|
|
23
|
+
"# User: $env:USERNAME"
|
|
24
|
+
"# Elevated: $elev"
|
|
25
|
+
"# Dry-run: $dry"
|
|
26
|
+
"# Launcher: $($ws.Launcher)"
|
|
27
|
+
'# ============================================================'
|
|
28
|
+
)
|
|
29
|
+
try { [IO.File]::WriteAllLines($ws.LogFile, $header) } catch { $ws.LogFile = $null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Write-Log {
|
|
33
|
+
<# .SYNOPSIS Append one timestamped line to the run log; never throws. #>
|
|
34
|
+
param([string] $Text)
|
|
35
|
+
$f = $Script:WS.LogFile
|
|
36
|
+
if (-not $f) { return }
|
|
37
|
+
try { [IO.File]::AppendAllText($f, ("[{0}] {1}`r`n" -f (Get-Date -Format 'HH:mm:ss'), $Text)) } catch { $null = $_ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Add-Freed {
|
|
41
|
+
<# .SYNOPSIS Tally bytes freed (or, in dry-run, bytes that would be freed). #>
|
|
42
|
+
param([long] $Bytes)
|
|
43
|
+
if ($Bytes -le 0) { return }
|
|
44
|
+
if ($Script:WS.DryRun) { $Script:WS.TotalEstimated += $Bytes } else { $Script:WS.TotalFreed += $Bytes }
|
|
45
|
+
$Script:WS.SectionFreed += $Bytes
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Get-DriveSnapshot {
|
|
49
|
+
<# .SYNOPSIS Size/free bytes of every ready fixed drive. #>
|
|
50
|
+
$rows = @()
|
|
51
|
+
foreach ($d in [IO.DriveInfo]::GetDrives()) {
|
|
52
|
+
try {
|
|
53
|
+
if (-not $d.IsReady) { continue }
|
|
54
|
+
if ($d.DriveType -ne [IO.DriveType]::Fixed) { continue }
|
|
55
|
+
$rows += [ordered]@{ drive = $d.Name.TrimEnd('\'); size_bytes = [long]$d.TotalSize; free_bytes = [long]$d.AvailableFreeSpace }
|
|
56
|
+
} catch { $null = $_ }
|
|
57
|
+
}
|
|
58
|
+
return $rows
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Get-SystemDriveFree {
|
|
62
|
+
<# .SYNOPSIS Free bytes on the system drive (used for before/after deltas). #>
|
|
63
|
+
try { return [long](New-Object IO.DriveInfo($env:SystemDrive)).AvailableFreeSpace } catch { return [long]0 }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function Show-DriveTable {
|
|
67
|
+
<# .SYNOPSIS Print size / used / free / percent for every fixed drive. #>
|
|
68
|
+
Write-UiLine (" {0,-6} {1,10} {2,10} {3,10} {4,6}" -f 'DRIVE', 'SIZE', 'USED', 'FREE', 'USE%') 'White'
|
|
69
|
+
foreach ($d in (Get-DriveSnapshot)) {
|
|
70
|
+
$used = $d.size_bytes - $d.free_bytes
|
|
71
|
+
$pct = 0
|
|
72
|
+
if ($d.size_bytes -gt 0) { $pct = [math]::Round(100.0 * $used / $d.size_bytes) }
|
|
73
|
+
$color = 'Gray'
|
|
74
|
+
if ($pct -ge 90) { $color = 'Red' } elseif ($pct -ge 80) { $color = 'Yellow' }
|
|
75
|
+
Write-UiLine (" {0,-6} {1,10} {2,10} {3,10} {4,5}%" -f $d.drive, (Format-Bytes $d.size_bytes), (Format-Bytes $used), (Format-Bytes $d.free_bytes), $pct) $color
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Initialize-Report {
|
|
80
|
+
$ws = $Script:WS
|
|
81
|
+
$ws.Report = @{
|
|
82
|
+
startedAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
|
|
83
|
+
startedTicks = (Get-Date).Ticks
|
|
84
|
+
drivesBefore = @(Get-DriveSnapshot)
|
|
85
|
+
steps = New-Object System.Collections.ArrayList
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Add-ReportStep {
|
|
90
|
+
<# .SYNOPSIS Record a section outcome for the JSON report. Status: ran | skipped | refused | failed | dry-run #>
|
|
91
|
+
param([int] $Section, [string] $Title, [string] $Status, [long] $Freed = 0, [string] $Note = '')
|
|
92
|
+
$ws = $Script:WS
|
|
93
|
+
if (-not $ws.Report) { return }
|
|
94
|
+
$null = $ws.Report.steps.Add([ordered]@{ n = ($ws.Report.steps.Count + 1); section = $Section; title = $Title; status = $Status; freed_bytes = $Freed; note = $Note })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Save-Report {
|
|
98
|
+
<# .SYNOPSIS Write the canonical JSON report (schema 1). Returns the path or $null. #>
|
|
99
|
+
$ws = $Script:WS
|
|
100
|
+
if ($ws.NoReport -or -not $ws.Report) { return $null }
|
|
101
|
+
$ran = 0; $skipped = 0
|
|
102
|
+
foreach ($s in $ws.Report.steps) { if ($s.status -eq 'ran' -or $s.status -eq 'dry-run') { $ran++ } else { $skipped++ } }
|
|
103
|
+
$dev = $null
|
|
104
|
+
if ($null -ne $ws.Developer) { $dev = [bool]$ws.Developer }
|
|
105
|
+
$doc = [ordered]@{
|
|
106
|
+
schema_version = 1
|
|
107
|
+
credits = [ordered]@{
|
|
108
|
+
tool = $Script:WS_NAME; tool_version = (Get-ToolVersion); tool_homepage = $Script:WS_REPO; tool_license = $Script:WS_LICENSE
|
|
109
|
+
author = [ordered]@{ name = $Script:WS_AUTHOR; email = $Script:WS_EMAIL; website = $Script:WS_WEB; linkedin = $Script:WS_LINKEDIN }
|
|
110
|
+
}
|
|
111
|
+
meta = [ordered]@{
|
|
112
|
+
started_at = $ws.Report.startedAt
|
|
113
|
+
finished_at = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
|
|
114
|
+
duration_seconds = [int][math]::Round(((Get-Date).Ticks - $ws.Report.startedTicks) / 10000000)
|
|
115
|
+
host = $env:COMPUTERNAME; user = $env:USERNAME
|
|
116
|
+
os = $ws.OsCaption; powershell = $PSVersionTable.PSVersion.ToString()
|
|
117
|
+
mode = $ws.Mode; dry_run = [bool]$ws.DryRun; elevated = [bool]$ws.IsAdmin; developer_mode = $dev
|
|
118
|
+
idle_days = $ws.Days; temp_days = $ws.TempDays
|
|
119
|
+
log_file = $ws.LogFile; launcher = $ws.Launcher; via_npx = [bool]$ws.Npx
|
|
120
|
+
}
|
|
121
|
+
disk = [ordered]@{ before = @($ws.Report.drivesBefore); after = @(Get-DriveSnapshot) }
|
|
122
|
+
steps = @($ws.Report.steps)
|
|
123
|
+
totals = [ordered]@{
|
|
124
|
+
total_reclaimed_bytes = [long]$ws.TotalFreed
|
|
125
|
+
total_reclaimed_human = (Format-Bytes $ws.TotalFreed)
|
|
126
|
+
total_estimated_bytes = [long]$ws.TotalEstimated
|
|
127
|
+
total_estimated_human = (Format-Bytes $ws.TotalEstimated)
|
|
128
|
+
steps_run = $ran; steps_skipped = $skipped
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
$path = Join-Path $ws.ReportsDir ('report-' + $ws.Stamp + '.json')
|
|
132
|
+
try {
|
|
133
|
+
[IO.File]::WriteAllText($path, ($doc | ConvertTo-Json -Depth 8), (New-Object System.Text.UTF8Encoding($false)))
|
|
134
|
+
$ws.ReportFile = $path
|
|
135
|
+
return $path
|
|
136
|
+
} catch {
|
|
137
|
+
Write-Warn "could not write report: $($_.Exception.Message)"
|
|
138
|
+
return $null
|
|
139
|
+
}
|
|
140
|
+
}
|
package/lib/safety.ps1
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# safety.ps1 - the deletion chokepoint and its guards.
|
|
2
|
+
#
|
|
3
|
+
# Every byte this tool removes passes through Remove-PathSafe (or Send-ToRecycleBin), which:
|
|
4
|
+
# 1. resolves the path and refuses relative segments, UNC paths, drive roots and system roots;
|
|
5
|
+
# 2. refuses every protected subtree / pattern / basename (Test-ProtectedPath);
|
|
6
|
+
# 3. asserts the path lies strictly inside the target root the caller declared (-Within);
|
|
7
|
+
# 4. short-circuits in --dry-run, printing what it would do and tallying an estimate;
|
|
8
|
+
# 5. removes reparse points as links, never following them; skips locked files, never fails a run.
|
|
9
|
+
# No flag bypasses steps 1-3.
|
|
10
|
+
|
|
11
|
+
function Initialize-Safety {
|
|
12
|
+
<# .SYNOPSIS Build the protected-path tables from the environment. Called once at startup. #>
|
|
13
|
+
$U = Get-FullPath $env:USERPROFILE
|
|
14
|
+
$L = Get-FullPath $env:LOCALAPPDATA
|
|
15
|
+
$A = Get-FullPath $env:APPDATA
|
|
16
|
+
$LL = Get-FullPath (Join-Path (Split-Path -Parent $L) 'LocalLow')
|
|
17
|
+
$SR = Get-FullPath $env:SystemRoot
|
|
18
|
+
$PD = Get-FullPath $env:ProgramData
|
|
19
|
+
$SD = $env:SystemDrive
|
|
20
|
+
$PF = Get-FullPath $env:ProgramFiles
|
|
21
|
+
$PF86 = Get-FullPath ${env:ProgramFiles(x86)}
|
|
22
|
+
|
|
23
|
+
$exact = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase)
|
|
24
|
+
foreach ($d in [IO.DriveInfo]::GetDrives()) { $null = $exact.Add($d.Name.TrimEnd('\')); $null = $exact.Add($d.Name) }
|
|
25
|
+
foreach ($p in @($SR, "$SR\System32", "$SR\SysWOW64", $PF, $PF86, $PD, "$SD\Users", "$SD\Users\Default", "$SD\Users\Public", $env:PUBLIC, $U, "$U\AppData", $A, $L, $LL)) {
|
|
26
|
+
if (-not $p) { continue }
|
|
27
|
+
$fp = Get-FullPath $p
|
|
28
|
+
if ($fp) { $null = $exact.Add($fp) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
$subtrees = @(
|
|
32
|
+
"$U\Documents", "$U\Pictures", "$U\Music", "$U\Videos", "$U\Desktop", "$U\Contacts", "$U\Favorites", "$U\Links",
|
|
33
|
+
"$U\Saved Games", "$U\Searches", "$U\3D Objects", "$U\Dropbox", "$U\Google Drive", "$U\iCloudDrive",
|
|
34
|
+
"$U\.ssh", "$U\.gnupg", "$U\.secrets", "$U\.aws", "$U\.azure", "$U\.kube", "$U\.gcloud", "$U\.docker", "$U\.config",
|
|
35
|
+
"$U\.local", "$U\.claude", "$U\.codex", "$U\.agents", "$U\.gemini", "$U\.copilot", "$U\.antigravity", "$U\.ollama",
|
|
36
|
+
"$U\.vscode-server", "$U\.cursor-server", "$U\.password-store",
|
|
37
|
+
"$A\npm", "$A\nvm", "$L\nvm", "$SD\nvm4w", "$L\Volta", "$L\fnm", "$L\fnm_multishells", "$L\node\corepack",
|
|
38
|
+
"$L\pnpm\global", "$U\.bun\bin", "$U\.bun\install\global", "$U\.deno\bin", "$U\.cargo\bin", "$U\.rustup", "$U\go\bin",
|
|
39
|
+
"$L\Programs", "$L\Microsoft\WindowsApps", "$L\Android\Sdk",
|
|
40
|
+
"$SR\Prefetch", "$SR\Installer", "$SR\WinSxS", "$SR\System32\config", "$SR\servicing", "$SR\Boot", "$SR\Fonts",
|
|
41
|
+
"$PD\Package Cache", "$PD\Microsoft\Windows\Start Menu",
|
|
42
|
+
"$SD\`$Recycle.Bin", "$SD\System Volume Information", "$SD\Recovery", "$SD\Boot", "$SD\EFI"
|
|
43
|
+
)
|
|
44
|
+
# Declared exceptions: paths inside a protected subtree that ARE regenerable caches.
|
|
45
|
+
$exceptions = @("$L\Android\Sdk\.temp", "$L\Android\Sdk\.downloadIntermediates")
|
|
46
|
+
|
|
47
|
+
# Wildcards matched against the full path. Browser/editor state that must never be mistaken for cache.
|
|
48
|
+
$patterns = @(
|
|
49
|
+
"$U\OneDrive*", "*\Packages\*\LocalState*", "*\Packages\*\Settings*", "*\Packages\*\RoamingState*",
|
|
50
|
+
'*chrome-for-testing*', '*\Chrome for Testing*',
|
|
51
|
+
'*\User Data\Default', '*\User Data\Guest Profile', '*\User Data\System Profile', '*\User Data\Profile ?', '*\User Data\Profile ??',
|
|
52
|
+
'*\User Data\*\Local Storage*', '*\User Data\*\Session Storage*', '*\User Data\*\IndexedDB*', '*\User Data\*\Cookies*',
|
|
53
|
+
'*\User Data\*\Login Data*', '*\User Data\*\Web Data*', '*\User Data\*\Bookmarks*', '*\User Data\*\History*',
|
|
54
|
+
'*\User Data\*\Sync Data*', '*\User Data\*\Preferences', '*\User Data\*\Secure Preferences', '*\User Data\*\Network\*',
|
|
55
|
+
'*\User Data\*\File System*', '*\User Data\*\databases*', '*\User Data\*\Sessions*', '*\User Data\*\Extension State*',
|
|
56
|
+
'*\User Data\*\Local Extension Settings*', '*\User Data\*\Sync Extension Settings*', '*\User Data\*\Extensions\*',
|
|
57
|
+
'*\User Data\*\Service Worker\Database*', '*\User Data\*\Service Worker\CacheStorage*', '*\User Data\*\shared_proto_db*',
|
|
58
|
+
'*\User\globalStorage*', '*\User\History*', '*\User\settings.json', '*\User\keybindings.json', '*\User\snippets*',
|
|
59
|
+
"$U\.cache\claude*", "$U\.cache\codex*", "$U\.cache\gemini*", "$U\.cache\copilot*", "$L\JetBrains\Toolbox*",
|
|
60
|
+
'*\Profiles\*\places.sqlite*', '*\Profiles\*\key4.db', '*\Profiles\*\logins.json', '*\Profiles\*\cookies.sqlite*',
|
|
61
|
+
'*\Profiles\*\storage\*', '*\Profiles\*\prefs.js', '*\Profiles\*\extensions\*'
|
|
62
|
+
)
|
|
63
|
+
$basenames = @('NTUSER.DAT', 'NTUSER.DAT.LOG1', 'NTUSER.DAT.LOG2', 'ntuser.ini', 'UsrClass.dat', 'UsrClass.dat.LOG1',
|
|
64
|
+
'UsrClass.dat.LOG2', 'hiberfil.sys', 'pagefile.sys', 'swapfile.sys', 'bootmgr', 'BOOTNXT', 'DumpStack.log.tmp')
|
|
65
|
+
|
|
66
|
+
# Pre-normalized once: Get-ProtectionReason runs once per file during a prune (400k+ files on a big yarn
|
|
67
|
+
# cache), so it must be plain string tests - no per-call path resolution, no per-call wildcard compile.
|
|
68
|
+
$subtreesNorm = @($subtrees | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
|
|
69
|
+
$exceptionsNorm = @($exceptions | ForEach-Object { Get-FullPath $_ } | Where-Object { $_ })
|
|
70
|
+
$wildcardIgnoreCase = [System.Management.Automation.WildcardOptions]::IgnoreCase
|
|
71
|
+
$Script:WS_PROTECT = @{
|
|
72
|
+
Exact = $exact
|
|
73
|
+
Subtrees = $subtreesNorm
|
|
74
|
+
SubtreePrefixes = @($subtreesNorm | ForEach-Object { $_ + '\' })
|
|
75
|
+
Exceptions = $exceptionsNorm
|
|
76
|
+
ExceptionPrefixes = @($exceptionsNorm | ForEach-Object { $_ + '\' })
|
|
77
|
+
Patterns = $patterns
|
|
78
|
+
PatternObjects = @($patterns | ForEach-Object { [System.Management.Automation.WildcardPattern]::Get($_, $wildcardIgnoreCase) })
|
|
79
|
+
Basenames = $basenames
|
|
80
|
+
HomePrefix = $null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Test-PathWithin {
|
|
85
|
+
<# .SYNOPSIS True when Path equals Within or lies inside it (case-insensitive, normalized). #>
|
|
86
|
+
param([string] $Path, [string] $Within)
|
|
87
|
+
$p = Get-FullPath $Path; $w = Get-FullPath $Within
|
|
88
|
+
if (-not $p -or -not $w) { return $false }
|
|
89
|
+
if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
90
|
+
$prefix = $w
|
|
91
|
+
if (-not $prefix.EndsWith('\')) { $prefix = $prefix + '\' }
|
|
92
|
+
return $p.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function Get-ProtectionReason {
|
|
96
|
+
<# .SYNOPSIS Why a path may not be deleted, or $null when it is allowed.
|
|
97
|
+
Hot path: called once per file during a prune, so it uses only the tables Initialize-Safety pre-normalized.
|
|
98
|
+
"$p\" starts with "$root\" is exactly Test-PathWithin (equal, or inside) without the per-call resolution. #>
|
|
99
|
+
param([string] $Path)
|
|
100
|
+
if ([string]::IsNullOrWhiteSpace($Path)) { return 'empty path' }
|
|
101
|
+
$raw = $Path.Trim()
|
|
102
|
+
if ($raw -match '(^|[\\/])\.\.([\\/]|$)') { return 'relative ".." segment' }
|
|
103
|
+
if ($raw.StartsWith('\\') -and -not $raw.StartsWith('\\?\')) { return 'UNC path' }
|
|
104
|
+
$p = Get-FullPath $raw
|
|
105
|
+
if (-not $p) { return 'unresolvable path' }
|
|
106
|
+
if ($p.StartsWith('\\')) { return 'UNC path' }
|
|
107
|
+
if ($p -match '^[A-Za-z]:\\?$') { return 'drive root' }
|
|
108
|
+
$pr = $Script:WS_PROTECT
|
|
109
|
+
if ($pr.Exact.Contains($p)) { return "protected root: $p" }
|
|
110
|
+
$ic = [StringComparison]::OrdinalIgnoreCase
|
|
111
|
+
$pSlash = $p + '\'
|
|
112
|
+
foreach ($e in $pr.ExceptionPrefixes) { if ($pSlash.StartsWith($e, $ic)) { return $null } }
|
|
113
|
+
for ($i = 0; $i -lt $pr.SubtreePrefixes.Count; $i++) {
|
|
114
|
+
if ($pSlash.StartsWith($pr.SubtreePrefixes[$i], $ic)) { return "inside protected: $($pr.Subtrees[$i])" }
|
|
115
|
+
}
|
|
116
|
+
for ($i = 0; $i -lt $pr.PatternObjects.Count; $i++) {
|
|
117
|
+
if ($pr.PatternObjects[$i].IsMatch($p)) { return "matches protected pattern: $($pr.Patterns[$i])" }
|
|
118
|
+
}
|
|
119
|
+
$base = [IO.Path]::GetFileName($p)
|
|
120
|
+
foreach ($b in $pr.Basenames) { if ($base.Equals($b, $ic)) { return "protected file name: $b" } }
|
|
121
|
+
if ($Script:WS.Home -and -not $Script:WS.AllowOwnData) {
|
|
122
|
+
if (-not $pr.HomePrefix) { $pr.HomePrefix = (Get-FullPath $Script:WS.Home) + '\' }
|
|
123
|
+
if ($pSlash.StartsWith($pr.HomePrefix, $ic)) { return 'the tool''s own data directory' }
|
|
124
|
+
}
|
|
125
|
+
return $null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Test-ProtectedPath {
|
|
129
|
+
param([string] $Path)
|
|
130
|
+
return ($null -ne (Get-ProtectionReason $Path))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function New-RemoveResult {
|
|
134
|
+
return [pscustomobject]@{ Removed = $false; Bytes = [long]0; Files = 0; Skipped = 0; SkippedBytes = [long]0; Reason = $null }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Remove-ReadOnlyAttribute {
|
|
138
|
+
param([string] $LongPath)
|
|
139
|
+
try {
|
|
140
|
+
$a = [IO.File]::GetAttributes($LongPath)
|
|
141
|
+
if (($a -band [IO.FileAttributes]::ReadOnly) -ne 0) { [IO.File]::SetAttributes($LongPath, ($a -bxor [IO.FileAttributes]::ReadOnly)) }
|
|
142
|
+
} catch { $null = $_ }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Remove-TreeInternal {
|
|
146
|
+
<# .SYNOPSIS Recursive delete that removes nested reparse points as links and skips locked files. Internal: guards already ran. #>
|
|
147
|
+
param([string] $Path, [pscustomobject] $Result)
|
|
148
|
+
$lp = Get-LongPath $Path
|
|
149
|
+
$entries = @()
|
|
150
|
+
try { $entries = (New-Object IO.DirectoryInfo($lp)).EnumerateFileSystemInfos() } catch { $Result.Skipped++; return $false }
|
|
151
|
+
$allClear = $true
|
|
152
|
+
foreach ($e in $entries) {
|
|
153
|
+
$isLink = (($e.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
|
|
154
|
+
$isDir = (($e.Attributes -band [IO.FileAttributes]::Directory) -ne 0)
|
|
155
|
+
try {
|
|
156
|
+
if ($isLink) {
|
|
157
|
+
if ($isDir) { [IO.Directory]::Delete($e.FullName, $false) } else { [IO.File]::Delete($e.FullName) }
|
|
158
|
+
Write-Log ("removed link (not followed): " + (Remove-LongPrefix $e.FullName))
|
|
159
|
+
} elseif ($isDir) {
|
|
160
|
+
if (-not (Remove-TreeInternal -Path $e.FullName -Result $Result)) { $allClear = $false }
|
|
161
|
+
} else {
|
|
162
|
+
Remove-ReadOnlyAttribute $e.FullName
|
|
163
|
+
[IO.File]::Delete($e.FullName)
|
|
164
|
+
$Result.Files++
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
$allClear = $false
|
|
168
|
+
$Result.Skipped++
|
|
169
|
+
try { if (-not $isDir) { $Result.SkippedBytes += [long]$e.Length } } catch { $null = $_ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if ($allClear) {
|
|
173
|
+
try { [IO.Directory]::Delete($lp, $false) } catch { $allClear = $false; $Result.Skipped++ }
|
|
174
|
+
}
|
|
175
|
+
return $allClear
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function Remove-PathSafe {
|
|
179
|
+
<# .SYNOPSIS The chokepoint. Removes one file, directory or link after every guard passes. Honours --dry-run. #>
|
|
180
|
+
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [string] $Label = '')
|
|
181
|
+
$ws = $Script:WS
|
|
182
|
+
$r = New-RemoveResult
|
|
183
|
+
$tag = ''
|
|
184
|
+
if ($Label) { $tag = " [$Label]" }
|
|
185
|
+
$p = Get-FullPath $Path
|
|
186
|
+
$w = Get-FullPath $Within
|
|
187
|
+
if (-not $p -or -not $w) { $r.Reason = 'unresolvable path'; Write-Err "REFUSE ($($r.Reason)): $Path"; return $r }
|
|
188
|
+
$why = Get-ProtectionReason $p
|
|
189
|
+
if ($why) { $r.Reason = $why; Write-Err "REFUSE ($why): $p"; return $r }
|
|
190
|
+
if ($p.Equals($w, [StringComparison]::OrdinalIgnoreCase)) { $r.Reason = 'target is its own declared root'; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
|
|
191
|
+
if (-not (Test-PathWithin -Path $p -Within $w)) { $r.Reason = "outside declared root $w"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r }
|
|
192
|
+
$info = Get-ItemInfo $p
|
|
193
|
+
if ($null -eq $info) { $r.Removed = $true; return $r }
|
|
194
|
+
$isDir = ($info -is [IO.DirectoryInfo])
|
|
195
|
+
$isLink = Test-ReparsePoint $info
|
|
196
|
+
$bytes = [long]0
|
|
197
|
+
if ($isLink) { $bytes = 0 } elseif ($isDir) { $bytes = (Get-DirectoryStats $p).Bytes } else { $bytes = [long]$info.Length }
|
|
198
|
+
if ($ws.DryRun) {
|
|
199
|
+
Write-DryRun ("would remove {0,10} {1}" -f (Format-Bytes $bytes), $p)
|
|
200
|
+
$r.Removed = $true; $r.Bytes = $bytes
|
|
201
|
+
Add-Freed $bytes
|
|
202
|
+
return $r
|
|
203
|
+
}
|
|
204
|
+
$lp = Get-LongPath $p
|
|
205
|
+
try {
|
|
206
|
+
if ($isLink) {
|
|
207
|
+
if ($isDir) { [IO.Directory]::Delete($lp, $false) } else { [IO.File]::Delete($lp) }
|
|
208
|
+
$r.Removed = $true
|
|
209
|
+
Write-Log "removed link (not followed): $p$tag"
|
|
210
|
+
} elseif ($isDir) {
|
|
211
|
+
$r.Removed = Remove-TreeInternal -Path $p -Result $r
|
|
212
|
+
$r.Bytes = [math]::Max([long]0, $bytes - $r.SkippedBytes)
|
|
213
|
+
if ($r.Skipped -gt 0) { Write-Note "$($r.Skipped) item(s) in use or inaccessible, left in place under $p" }
|
|
214
|
+
Write-Log ("removed dir: $p ($($r.Bytes) bytes, skipped $($r.Skipped))$tag")
|
|
215
|
+
} else {
|
|
216
|
+
Remove-ReadOnlyAttribute $lp
|
|
217
|
+
[IO.File]::Delete($lp)
|
|
218
|
+
$r.Removed = $true; $r.Bytes = $bytes; $r.Files = 1
|
|
219
|
+
Write-Log "removed file: $p ($bytes bytes)$tag"
|
|
220
|
+
}
|
|
221
|
+
Add-Freed $r.Bytes
|
|
222
|
+
} catch {
|
|
223
|
+
$r.Skipped++
|
|
224
|
+
$r.Reason = 'in use or access denied'
|
|
225
|
+
Write-Note "in use or inaccessible, skipped: $p"
|
|
226
|
+
Write-Log "skip (locked): $p - $($_.Exception.Message)"
|
|
227
|
+
}
|
|
228
|
+
return $r
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function Remove-StaleFiles {
|
|
232
|
+
<# .SYNOPSIS Prune files idle >= Days under Root (both timestamps rule), then sweep empty directories. #>
|
|
233
|
+
param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days, [string] $Label = '')
|
|
234
|
+
$ws = $Script:WS
|
|
235
|
+
$out = [pscustomobject]@{ Freed = [long]0; Files = 0; Skipped = 0; Exists = $false }
|
|
236
|
+
if (-not $Label) { $Label = $Root }
|
|
237
|
+
$root = Get-FullPath $Root
|
|
238
|
+
if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
|
|
239
|
+
if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
|
|
240
|
+
$out.Exists = $true
|
|
241
|
+
$why = Get-ProtectionReason $root
|
|
242
|
+
if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
|
|
243
|
+
if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
|
|
244
|
+
$scan = Get-StaleFiles -Root $root -Days $Days
|
|
245
|
+
$total = [long]0
|
|
246
|
+
foreach ($f in $scan.Files) { $total += $f.Bytes }
|
|
247
|
+
if ($scan.Files.Count -eq 0) { Write-Info "$Label - nothing idle for $Days+ days"; return $out }
|
|
248
|
+
if ($ws.DryRun) {
|
|
249
|
+
Write-DryRun ("would prune {0} in {1} files idle {2}+ days from {3}" -f (Format-Bytes $total), $scan.Files.Count, $Days, $root)
|
|
250
|
+
$out.Freed = $total; $out.Files = $scan.Files.Count
|
|
251
|
+
Add-Freed $total
|
|
252
|
+
return $out
|
|
253
|
+
}
|
|
254
|
+
foreach ($f in $scan.Files) {
|
|
255
|
+
$why = Get-ProtectionReason $f.Path
|
|
256
|
+
if ($why) { $out.Skipped++; continue }
|
|
257
|
+
try {
|
|
258
|
+
Remove-ReadOnlyAttribute $f.Path
|
|
259
|
+
[IO.File]::Delete($f.Path)
|
|
260
|
+
$out.Freed += $f.Bytes; $out.Files++
|
|
261
|
+
} catch { $out.Skipped++ }
|
|
262
|
+
}
|
|
263
|
+
# Sweep directories that became empty, deepest first; never the root itself.
|
|
264
|
+
$dirs = @($scan.Dirs | Sort-Object { $_.Length } -Descending)
|
|
265
|
+
foreach ($d in $dirs) {
|
|
266
|
+
try {
|
|
267
|
+
$di = New-Object IO.DirectoryInfo($d)
|
|
268
|
+
if (($di.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
|
|
269
|
+
$any = $false
|
|
270
|
+
foreach ($x in $di.EnumerateFileSystemInfos()) { $any = $true; break }
|
|
271
|
+
if (-not $any) { [IO.Directory]::Delete($d, $false) }
|
|
272
|
+
} catch { $null = $_ }
|
|
273
|
+
}
|
|
274
|
+
Add-Freed $out.Freed
|
|
275
|
+
Write-Log ("pruned $($out.Files) files ($($out.Freed) bytes) idle $Days+ days from $root; skipped $($out.Skipped)")
|
|
276
|
+
Write-Ok ("$Label - pruned {0} in {1} files idle {2}+ days ({3} skipped in use)" -f (Format-Bytes $out.Freed), $out.Files, $Days, $out.Skipped)
|
|
277
|
+
return $out
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function Remove-StaleUnits {
|
|
281
|
+
<# .SYNOPSIS Treat each top-level child as one unit (a tool version, an AVD). Delete units idle >= Days;
|
|
282
|
+
-KeepNewest never removes the freshest unit of each group (-GroupBy maps a name to its group; default: one group). #>
|
|
283
|
+
param([Parameter(Mandatory = $true)][string] $Root, [Parameter(Mandatory = $true)][string] $Within, [int] $Days,
|
|
284
|
+
[string] $Pattern = '*', [switch] $KeepNewest, [switch] $DirectoriesOnly, [scriptblock] $GroupBy = $null,
|
|
285
|
+
[string[]] $ExcludeNames = @(), [string] $Label = '')
|
|
286
|
+
$out = [pscustomobject]@{ Inspected = 0; Pruned = 0; Kept = 0; Freed = [long]0; Exists = $false }
|
|
287
|
+
if (-not $Label) { $Label = $Root }
|
|
288
|
+
$root = Get-FullPath $Root
|
|
289
|
+
if (-not (Test-DirPresent $root)) { Write-Info "$Label - absent"; return $out }
|
|
290
|
+
if (Test-ReparsePoint (Get-ItemInfo $root)) { Write-Note "$Label - is a link, not entered"; return $out }
|
|
291
|
+
$out.Exists = $true
|
|
292
|
+
$why = Get-ProtectionReason $root
|
|
293
|
+
if ($why) { Write-Err "REFUSE ($why): $root"; return $out }
|
|
294
|
+
if (-not (Test-PathWithin -Path $root -Within $Within)) { Write-Err "REFUSE (outside declared root): $root"; return $out }
|
|
295
|
+
$units = @()
|
|
296
|
+
foreach ($e in (Get-ChildEntries $root)) {
|
|
297
|
+
if ($DirectoriesOnly -and -not ($e -is [IO.DirectoryInfo])) { continue }
|
|
298
|
+
if ($e.Name -notlike $Pattern) { continue }
|
|
299
|
+
if ($ExcludeNames -contains $e.Name) { continue }
|
|
300
|
+
if (Test-ReparsePoint $e) { continue }
|
|
301
|
+
$full = Remove-LongPrefix $e.FullName
|
|
302
|
+
$group = 'all'
|
|
303
|
+
if ($GroupBy) { $group = [string](& $GroupBy $e.Name) }
|
|
304
|
+
$units += [pscustomobject]@{ Name = $e.Name; Path = $full; Idle = (Get-IdleDays $full); IsDir = ($e -is [IO.DirectoryInfo]); Group = $group }
|
|
305
|
+
}
|
|
306
|
+
if ($units.Count -eq 0) { Write-Info "$Label - nothing to inspect"; return $out }
|
|
307
|
+
$keep = @{}
|
|
308
|
+
if ($KeepNewest) {
|
|
309
|
+
foreach ($g in ($units | Group-Object Group)) {
|
|
310
|
+
$freshest = $g.Group | Sort-Object Idle | Select-Object -First 1
|
|
311
|
+
$keep[$freshest.Path] = $true
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
foreach ($u in ($units | Sort-Object Name)) {
|
|
315
|
+
$out.Inspected++
|
|
316
|
+
if ($KeepNewest -and $keep.ContainsKey($u.Path)) {
|
|
317
|
+
Write-Note ("kept {0} (newest of its kind, {1}d idle)" -f $u.Name, $u.Idle)
|
|
318
|
+
$out.Kept++
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if ($u.Idle -ge $Days) {
|
|
322
|
+
$r = Remove-PathSafe -Path $u.Path -Within $Within -Label $u.Name
|
|
323
|
+
if ($r.Removed) {
|
|
324
|
+
$out.Pruned++; $out.Freed += $r.Bytes
|
|
325
|
+
if (-not $Script:WS.DryRun) { Write-Ok ("pruned {0} ({1}d idle, {2})" -f $u.Name, $u.Idle, (Format-Bytes $r.Bytes)) }
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
Write-Note ("kept {0} ({1}d idle, within the {2}-day window)" -f $u.Name, $u.Idle, $Days)
|
|
329
|
+
$out.Kept++
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return $out
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function Clear-DirectoryContents {
|
|
336
|
+
<# .SYNOPSIS Remove everything inside Path (never Path itself). -OlderThanDays limits it to idle entries. #>
|
|
337
|
+
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within, [int] $OlderThanDays = 0, [string] $Label = '')
|
|
338
|
+
$out = [pscustomobject]@{ Freed = [long]0; Items = 0; Skipped = 0; Exists = $false }
|
|
339
|
+
if (-not $Label) { $Label = $Path }
|
|
340
|
+
$p = Get-FullPath $Path
|
|
341
|
+
if (-not (Test-DirPresent $p)) { Write-Info "$Label - absent"; return $out }
|
|
342
|
+
if (Test-ReparsePoint (Get-ItemInfo $p)) { Write-Note "$Label - is a link, not entered"; return $out }
|
|
343
|
+
$out.Exists = $true
|
|
344
|
+
$why = Get-ProtectionReason $p
|
|
345
|
+
if ($why) { Write-Err "REFUSE ($why): $p"; return $out }
|
|
346
|
+
if (-not (Test-PathWithin -Path $p -Within $Within)) { Write-Err "REFUSE (outside declared root $Within): $p"; return $out }
|
|
347
|
+
$entries = @(Get-ChildEntries $p)
|
|
348
|
+
if ($entries.Count -eq 0) { Write-Info "$Label - already empty"; return $out }
|
|
349
|
+
if ($Script:WS.DryRun) {
|
|
350
|
+
# One line per folder in dry-run: the estimate is the sum of what each eligible child would free.
|
|
351
|
+
$est = [long]0; $n = 0
|
|
352
|
+
foreach ($e in $entries) {
|
|
353
|
+
$full = Remove-LongPrefix $e.FullName
|
|
354
|
+
if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
|
|
355
|
+
if (Get-ProtectionReason $full) { continue }
|
|
356
|
+
if (Test-ReparsePoint $e) { $n++; continue }
|
|
357
|
+
if ($e -is [IO.DirectoryInfo]) { $est += (Get-DirectoryStats $full).Bytes } else { $est += [long]$e.Length }
|
|
358
|
+
$n++
|
|
359
|
+
}
|
|
360
|
+
if ($n -eq 0) { Write-Info "$Label - nothing eligible"; return $out }
|
|
361
|
+
$age = ''
|
|
362
|
+
if ($OlderThanDays -gt 0) { $age = " idle $OlderThanDays+ days" }
|
|
363
|
+
Write-DryRun ("would clear {0} in {1} items{2} from {3}" -f (Format-Bytes $est), $n, $age, $p)
|
|
364
|
+
Add-Freed $est
|
|
365
|
+
$out.Items = $n; $out.Freed = $est
|
|
366
|
+
return $out
|
|
367
|
+
}
|
|
368
|
+
foreach ($e in $entries) {
|
|
369
|
+
$full = Remove-LongPrefix $e.FullName
|
|
370
|
+
if ($OlderThanDays -gt 0 -and (Get-IdleDays $full) -lt $OlderThanDays) { continue }
|
|
371
|
+
$r = Remove-PathSafe -Path $full -Within $p -Label $Label
|
|
372
|
+
if ($r.Removed) { $out.Items++; $out.Freed += $r.Bytes }
|
|
373
|
+
$out.Skipped += $r.Skipped
|
|
374
|
+
}
|
|
375
|
+
if (-not $Script:WS.DryRun -and $out.Items -gt 0) {
|
|
376
|
+
Write-Ok ("$Label - cleared {0} ({1} items, {2} skipped in use)" -f (Format-Bytes $out.Freed), $out.Items, $out.Skipped)
|
|
377
|
+
} elseif (-not $Script:WS.DryRun) {
|
|
378
|
+
Write-Info "$Label - nothing eligible"
|
|
379
|
+
}
|
|
380
|
+
return $out
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function Send-ToRecycleBin {
|
|
384
|
+
<# .SYNOPSIS Move a personal file/folder to the Recycle Bin (or delete when --permanent). Same guards as Remove-PathSafe. #>
|
|
385
|
+
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][string] $Within)
|
|
386
|
+
$ws = $Script:WS
|
|
387
|
+
if ($ws.Permanent) { return (Remove-PathSafe -Path $Path -Within $Within) }
|
|
388
|
+
$r = New-RemoveResult
|
|
389
|
+
$p = Get-FullPath $Path
|
|
390
|
+
$why = Get-ProtectionReason $p
|
|
391
|
+
if ($why) { $r.Reason = $why; Write-Err "REFUSE ($why): $p"; return $r }
|
|
392
|
+
if (-not (Test-PathWithin -Path $p -Within $Within) -or $p.Equals((Get-FullPath $Within), [StringComparison]::OrdinalIgnoreCase)) {
|
|
393
|
+
$r.Reason = "outside declared root $Within"; Write-Err "REFUSE ($($r.Reason)): $p"; return $r
|
|
394
|
+
}
|
|
395
|
+
$info = Get-ItemInfo $p
|
|
396
|
+
if ($null -eq $info) { $r.Removed = $true; return $r }
|
|
397
|
+
$bytes = [long](Get-DirectoryStats $p).Bytes
|
|
398
|
+
if ($ws.DryRun) { Write-DryRun ("would move to Recycle Bin {0,10} {1}" -f (Format-Bytes $bytes), $p); $r.Removed = $true; $r.Bytes = $bytes; Add-Freed $bytes; return $r }
|
|
399
|
+
try {
|
|
400
|
+
Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop
|
|
401
|
+
if ($info -is [IO.DirectoryInfo]) {
|
|
402
|
+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
|
|
403
|
+
} else {
|
|
404
|
+
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
|
|
405
|
+
}
|
|
406
|
+
$r.Removed = $true; $r.Bytes = $bytes
|
|
407
|
+
Add-Freed $bytes
|
|
408
|
+
Write-Log "recycled: $p ($bytes bytes)"
|
|
409
|
+
Write-Ok ("moved to Recycle Bin: {0} ({1})" -f $p, (Format-Bytes $bytes))
|
|
410
|
+
} catch {
|
|
411
|
+
$r.Skipped++; $r.Reason = $_.Exception.Message
|
|
412
|
+
Write-Warn "could not recycle $p - $($_.Exception.Message)"
|
|
413
|
+
}
|
|
414
|
+
return $r
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function Invoke-External {
|
|
418
|
+
<# .SYNOPSIS Run an external command, streaming its output to the log. -Destructive commands are skipped in --dry-run. #>
|
|
419
|
+
param([Parameter(Mandatory = $true)][string] $FilePath, [string[]] $ArgumentList = @(), [switch] $Destructive, [switch] $Quiet, [string] $Label = '')
|
|
420
|
+
$display = "$FilePath $($ArgumentList -join ' ')"
|
|
421
|
+
$tag = ''
|
|
422
|
+
if ($Label) { $tag = " [$Label]" }
|
|
423
|
+
if ($Destructive -and $Script:WS.DryRun) {
|
|
424
|
+
Write-DryRun "would run: $display"
|
|
425
|
+
return [pscustomobject]@{ Ran = $false; ExitCode = 0; Output = @() }
|
|
426
|
+
}
|
|
427
|
+
Write-Log "run${tag}: $display"
|
|
428
|
+
$lines = @()
|
|
429
|
+
try {
|
|
430
|
+
$raw = & $FilePath @ArgumentList 2>&1
|
|
431
|
+
foreach ($l in $raw) {
|
|
432
|
+
$s = [string]$l
|
|
433
|
+
$lines += $s
|
|
434
|
+
if (-not $Quiet) { Write-Note $s } else { Write-Log $s }
|
|
435
|
+
}
|
|
436
|
+
$code = $LASTEXITCODE
|
|
437
|
+
if ($null -eq $code) { $code = 0 }
|
|
438
|
+
} catch {
|
|
439
|
+
$lines += $_.Exception.Message
|
|
440
|
+
$code = 1
|
|
441
|
+
}
|
|
442
|
+
Write-Log "exit $code$tag : $display"
|
|
443
|
+
return [pscustomobject]@{ Ran = $true; ExitCode = $code; Output = $lines }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function Test-CommandPresent {
|
|
447
|
+
param([string] $Name)
|
|
448
|
+
return ($null -ne (Get-Command $Name -ErrorAction SilentlyContinue))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function Test-ProcessRunning {
|
|
452
|
+
<# .SYNOPSIS True when any process with one of the given names is running. #>
|
|
453
|
+
param([string[]] $Names)
|
|
454
|
+
foreach ($n in $Names) {
|
|
455
|
+
if (@(Get-Process -Name $n -ErrorAction SilentlyContinue).Count -gt 0) { return $true }
|
|
456
|
+
}
|
|
457
|
+
return $false
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function Test-IsAdmin {
|
|
461
|
+
try {
|
|
462
|
+
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
463
|
+
return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
464
|
+
} catch { return $false }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function Test-CanElevate {
|
|
468
|
+
<# .SYNOPSIS True when the account belongs to Administrators (even in a filtered, non-elevated token). #>
|
|
469
|
+
try {
|
|
470
|
+
$out = & "$env:SystemRoot\System32\whoami.exe" /groups /fo csv 2>$null
|
|
471
|
+
foreach ($l in $out) { if ($l -match 'S-1-5-32-544') { return $true } }
|
|
472
|
+
} catch { $null = $_ }
|
|
473
|
+
return $false
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function Invoke-Elevated {
|
|
477
|
+
<# .SYNOPSIS Relaunch this run elevated (UAC prompt) and wait for it. Returns the child's exit code. #>
|
|
478
|
+
$ws = $Script:WS
|
|
479
|
+
$childArgs = @('-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $ws.ScriptPath + '"'))
|
|
480
|
+
foreach ($a in $ws.RawArgs) {
|
|
481
|
+
if ($a -eq '--elevate') { continue }
|
|
482
|
+
if ($a -match '[\s"]') { $childArgs += ('"' + ($a -replace '"', '\"') + '"') } else { $childArgs += $a }
|
|
483
|
+
}
|
|
484
|
+
$childArgs += '--elevated-child'
|
|
485
|
+
Write-Info 'Requesting elevation (a UAC prompt appears). The elevated run opens in a new window and writes its own log.'
|
|
486
|
+
try {
|
|
487
|
+
$proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $childArgs -Verb RunAs -Wait -PassThru
|
|
488
|
+
return $proc.ExitCode
|
|
489
|
+
} catch {
|
|
490
|
+
Write-Err "elevation refused or failed: $($_.Exception.Message)"
|
|
491
|
+
return $Script:WS_EXIT_REFUSED
|
|
492
|
+
}
|
|
493
|
+
}
|