diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e2bc782 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +# Default: let Git normalize line endings based on file content detection. +* text=auto + +# PowerShell scripts: CRLF on checkout matches Windows tooling and avoids PS5 parser +# quirks that can surface when source files land in $env:USERPROFILE\Documents\. +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.psd1 text eol=crlf + +# JSON / Markdown / YAML / config: LF is standard and works everywhere. +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.gitignore text eol=lf +*.gitattributes text eol=lf + +# Binary assets: tell Git not to attempt diff or EOL conversion. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.7z binary +*.tar binary +*.gz binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary + +# GitHub Linguist: test harnesses shouldn't inflate language stats or drown out +# the profile in the language bar. +tests/** linguist-detectable=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69e5535..b250335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,10 @@ jobs: 'PSUseBOMForUnicodeEncodedFile' # Not needed 'PSReviewUnusedParameter' # Scriptblock params required by completer API 'PSUseSingularNouns' # Style preference - ) | Where-Object { $_.ScriptName -ne 'ci-functional.ps1' } - # ci-functional.ps1 is excluded: it intentionally calls profile aliases (gc, ls, cat, uptime, - # eventlog) by their short names to exercise the profile's own function definitions. + ) | Where-Object { $_.ScriptName -notin @('ci-functional.ps1', 'test.ps1', 'rawhunt.ps1', 'locallab.ps1', 'lint.ps1') } + # tests/ is excluded: those files intentionally call profile aliases (gc, ls, cat, uptime, + # eventlog) by their short names to exercise the profile's own function definitions, and + # contain patterns that trip secrets/path scanners on their own test data. $results | Format-Table -AutoSize if ($results | Where-Object Severity -in 'Error','Warning') { Write-Error "PSScriptAnalyzer found warnings or errors" @@ -47,12 +48,23 @@ jobs: shell: pwsh run: | $env:CI = 'true' - pwsh -NonInteractive -NoProfile -Command ". '${{ github.workspace }}/Microsoft.PowerShell_profile.ps1'" - if ($LASTEXITCODE -ne 0) { - Write-Error "Profile failed to load non-interactive (exit code: $LASTEXITCODE)" + # $LASTEXITCODE alone misses non-terminating errors (a profile can emit errors and still + # exit 0). Capture the child's streams and fail on any ErrorRecord. Warnings are suppressed + # in the child because user-file/plugin load failures are non-fatal by design. + $smoke = pwsh -NonInteractive -NoProfile -Command "`$WarningPreference='SilentlyContinue'; . '${{ github.workspace }}/Microsoft.PowerShell_profile.ps1'" 2>&1 + $code = $LASTEXITCODE + $smokeErrors = @($smoke | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) + if ($code -ne 0) { + $smokeErrors | ForEach-Object { Write-Host $_ -ForegroundColor Red } + Write-Error "Profile failed to load non-interactive (exit code: $code)" exit 1 } - Write-Host "Profile loaded successfully in non-interactive mode." -ForegroundColor Green + if ($smokeErrors.Count -gt 0) { + $smokeErrors | ForEach-Object { Write-Host $_ -ForegroundColor Red } + Write-Error "Profile loaded but emitted $($smokeErrors.Count) error(s) during load (non-terminating errors are still failures)" + exit 1 + } + Write-Host "Profile loaded successfully in non-interactive mode (no errors)." -ForegroundColor Green - name: PS5 parse-check (all .ps1 files) shell: powershell @@ -86,7 +98,9 @@ jobs: '/home/' '\\\\Users\\\\' ) - $files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' } + # tests/test.ps1 replicates this same check locally, so it contains the regex patterns + # we are searching for; excluded by name to avoid self-matching. + $files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne 'test.ps1' } $found = @() foreach ($file in $files) { $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue @@ -154,7 +168,8 @@ jobs: - name: Check for Set-Content -Encoding UTF8 shell: pwsh run: | - $files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' } + # tests/test.ps1 replicates this scan and contains the regex itself; excluded by name. + $files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne 'test.ps1' } $found = @() foreach ($file in $files) { $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue @@ -187,7 +202,8 @@ jobs: 'sk-[A-Za-z0-9]{32,}' '(?i)connectionstring\s*[:=]\s*[''"]Server=' ) - $files = Get-ChildItem -Recurse -Include *.ps1,*.md,*.yml,*.json | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.FullName -notlike '*\.github\workflows\*' } + # tests/test.ps1 contains secret-detection regex patterns locally; excluded by name. + $files = Get-ChildItem -Recurse -Include *.ps1,*.md,*.yml,*.json | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.FullName -notlike '*\.github\workflows\*' -and $_.Name -ne 'test.ps1' } $found = @() foreach ($file in $files) { $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue @@ -254,6 +270,67 @@ jobs: } Write-Host "Config schema valid." -ForegroundColor Green + - name: Validate wizard curated data (CuratedSchemes + CuratedFonts) + shell: pwsh + run: | + # Extract $script:CuratedSchemes and $script:CuratedFonts from setup.ps1 via AST + # and assert each entry has all required fields. Avoids running setup.ps1 itself. + $setup = Join-Path $PWD 'setup.ps1' + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($setup, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { Write-Error "setup.ps1 has parse errors"; exit 1 } + + $assignments = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and + ($n.Left.Extent.Text -in @('$script:CuratedSchemes', '$script:CuratedFonts')) + }, $true) + + $found = @{} + foreach ($a in $assignments) { + $name = $a.Left.Extent.Text.TrimStart('$').Replace('script:','') + Invoke-Expression $a.Extent.Text + $found[$name] = Get-Variable -Name $name -Scope Local -ValueOnly + } + + $errors = 0 + # Schemes: expect 7 entries, each with Name, Desc, and a nested Scheme with name + background + $schemes = $found['CuratedSchemes'] + if (-not $schemes) { Write-Host "FAIL: CuratedSchemes not found" -ForegroundColor Red; exit 1 } + if ($schemes.Count -ne 7) { Write-Host "FAIL: expected 7 curated schemes, got $($schemes.Count)" -ForegroundColor Red; $errors++ } + $schemeReq = @('Name','Desc','Scheme') + $schemeInnerReq = @('name','background','foreground','black','red','green','yellow','blue','purple','cyan','white') + foreach ($s in $schemes) { + foreach ($k in $schemeReq) { + if (-not $s.ContainsKey($k)) { Write-Host "FAIL: scheme '$($s.Name)' missing '$k'" -ForegroundColor Red; $errors++ } + } + if ($s.Scheme) { + foreach ($k in $schemeInnerReq) { + if (-not $s.Scheme.ContainsKey($k)) { Write-Host "FAIL: scheme '$($s.Name)' missing Scheme.$k" -ForegroundColor Red; $errors++ } + } + if ($s.Scheme.background -and $s.Scheme.background -notmatch '^#[0-9a-fA-F]{6}$') { + Write-Host "FAIL: scheme '$($s.Name)' background not a #RRGGBB hex: $($s.Scheme.background)" -ForegroundColor Red; $errors++ + } + } + } + Write-Host "OK: $($schemes.Count) curated schemes validated" -ForegroundColor Green + + # Fonts: expect 6 entries, each with Asset, DisplayName, Desc + $fonts = $found['CuratedFonts'] + if (-not $fonts) { Write-Host "FAIL: CuratedFonts not found" -ForegroundColor Red; exit 1 } + if ($fonts.Count -ne 6) { Write-Host "FAIL: expected 6 curated fonts, got $($fonts.Count)" -ForegroundColor Red; $errors++ } + $fontReq = @('Asset','DisplayName','Desc') + foreach ($f in $fonts) { + foreach ($k in $fontReq) { + if (-not $f.ContainsKey($k) -or [string]::IsNullOrWhiteSpace([string]$f[$k])) { + Write-Host "FAIL: font '$($f.Asset)' missing '$k'" -ForegroundColor Red; $errors++ + } + } + } + Write-Host "OK: $($fonts.Count) curated fonts validated" -ForegroundColor Green + + if ($errors -gt 0) { exit 1 } + - name: Dry-run setup.ps1 (parse + function definitions) shell: pwsh run: | @@ -262,7 +339,7 @@ jobs: $content = Get-Content 'setup.ps1' -Raw # Verify required functions are defined in setup.ps1 - $requiredFunctions = @('Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', 'Install-WingetPackage', 'Merge-JsonObject', 'Select-PreferredEditor', 'Invoke-DownloadWithRetry') + $requiredFunctions = @('Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', 'Install-WingetPackage', 'Merge-JsonObject', 'Select-PreferredEditor', 'Invoke-DownloadWithRetry', 'Remove-SafeTempDirectory', 'Update-SessionPathFromRegistry') $errors = 0 foreach ($fn in $requiredFunctions) { if ($content -notmatch "function\s+$fn\b") { @@ -279,7 +356,8 @@ jobs: run: | # Extract Merge-JsonObject from setup.ps1 and test it (with null guard like production) function Merge-JsonObject($base, $override) { - if (-not $base -or -not $override) { return } + if ($null -eq $override) { return } + if ($null -eq $base) { throw 'Merge-JsonObject: $base cannot be null (caller must pass an object to merge into).' } foreach ($prop in $override.PSObject.Properties) { $baseVal = $base.PSObject.Properties[$prop.Name] if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) { @@ -320,11 +398,13 @@ jobs: } Write-Host "OK: object replacement" -ForegroundColor Green - # Test 4: null base or override returns without throwing (matches production guard) + # Test 4: null override is a no-op; null base throws (caller-bug surfacing) $base = [PSCustomObject]@{ a = 1 } - Merge-JsonObject $null $base Merge-JsonObject $base $null - if ($base.a -ne 1) { Write-Error "Null guard should not modify base"; exit 1 } + if ($base.a -ne 1) { Write-Error "Null override should not modify base"; exit 1 } + $threw = $false + try { Merge-JsonObject $null ([PSCustomObject]@{ a = 1 }) } catch { $threw = $true } + if (-not $threw) { Write-Error "Null base should throw"; exit 1 } Write-Host "OK: null guard" -ForegroundColor Green - name: Test WT settings merge (mock) @@ -379,8 +459,8 @@ jobs: if ($errors -gt 0) { exit 1 } - # Verify JSON roundtrip - $json = $mockWt | ConvertTo-Json -Depth 10 + # Verify JSON roundtrip (depth matches production: profile/setup write WT settings with -Depth 100) + $json = $mockWt | ConvertTo-Json -Depth 100 $null = $json | ConvertFrom-Json -ErrorAction Stop Write-Host "OK: WT settings merge + JSON roundtrip" -ForegroundColor Green @@ -398,4 +478,4 @@ jobs: - name: Run functional profile tests shell: pwsh run: | - pwsh -NoProfile -File '${{ github.workspace }}/ci-functional.ps1' + pwsh -NoProfile -File '${{ github.workspace }}/tests/ci-functional.ps1' diff --git a/.gitignore b/.gitignore index 5aef44a..809d5be 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,5 @@ profile_user.ps1 .tmpcmd.ps1 LastExecutionTime.txt -# Local test/lint scripts -lint2.ps1 -test.ps1 -rawhunt.ps1 +# Experimental / scratch files (underscore-prefixed stay untracked) +_*.ps1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc5f3ec..6d69351 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,23 @@ All code must work under both PowerShell 5.1 and 7+. Key differences: Also set `UpgradeStrategy` (`winget` for normal tools, `preserve-direct` only when a direct/MSI install must not be pushed back through winget). 2. Add a numbered install step in `setup.ps1` (it cannot read `ProfileTools`) +### Adding a New User-Facing Command + +1. Write the function in `Microsoft.PowerShell_profile.ps1`. Prefer approved PowerShell verbs for `Verb-Noun` names; short non-verb names are acceptable for Unix-style utilities (`grep`, `journal`, `nscan`). +2. Seed the command registry so it shows up in `Get-ProfileCommand` and `Start-ProfileTour`. Add an entry to the `$script:_seedCommands` array near the end of the profile: + + ```powershell + @{ Name = 'mycmd'; Category = 'Developer'; Synopsis = 'One-line description' } + ``` + +3. Add an `Invoke-CommandProbe` entry in `tests/ci-functional.ps1` - either executing real code or `-SkipReason '...'` for destructive/interactive/tool-dependent commands. CI enforces 100% coverage, so missing a probe fails the `functional` job. +4. If the command has nested internal helpers (e.g. `Write-JournalLine` inside `journal`), add them to `$internalOnly` in the coverage audit so they are not flagged as missing public commands. +5. Update the appropriate section in `Show-Help` and `README.md`. + +### Adding an Argument Completer + +For new commands with parameters that benefit from Tab-complete (ports, log names, modes with descriptions, etc.), add a `Register-ArgumentCompleter` block near the top of the Sysadmin section. Use `$null = $commandName, $parameterName, $commandAst, $fakeBoundParameters` to mark unused `param()` entries as intentional and satisfy `PSReviewUnusedParameter`. + ## Running the Linter CI uses PSScriptAnalyzer and fails on both warnings and errors. Run it locally before pushing: @@ -84,6 +101,6 @@ CI runs on push/PR to `main` with three jobs: - **lint**: PSScriptAnalyzer, smoke test, PS5 parse, hardcoded-path check, non-ASCII/BOM/secrets checks - **install-flow**: JSON config validation, schema checks, Merge-JsonObject tests, WT merge mock, required function checks (`Test-InternetConnection`, `Install-NerdFonts`, `Install-OhMyPoshTheme`, `Install-WingetPackage`, `Merge-JsonObject`, `Select-PreferredEditor`, `Invoke-DownloadWithRetry`) -- **functional**: Runs `ci-functional.ps1` (elevated): full install flow, sandbox install/execute/uninstall, and 100% command-probe coverage +- **functional**: Runs `tests/ci-functional.ps1` (elevated): full install flow, sandbox install/execute/uninstall, and 100% command-probe coverage All three jobs must pass. CI also fails on hardcoded user paths and embedded secrets. diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index a28c90a..c6bc7d3 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -17,22 +17,38 @@ $isInteractive = [Environment]::UserInteractive -and -not $(try { [Console]::IsOutputRedirected } catch { $false }) -and -not ([Environment]::GetCommandLineArgs() | Where-Object { $_ -match '(?i)^-NonI' }) +# Raise the TLS floor to 1.2 on Windows PowerShell 5.1 so every later network command (Update-Profile, pubip, +# winutil, ...) negotiates a modern protocol; older .NET 4.x can default to SSL3/TLS1.0 which GitHub rejects. +# PowerShell 7 (Core) negotiates via the OS and needs no change. +if ($PSVersionTable.PSVersion.Major -lt 6) { + try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { $null = $_ } +} + $repo_root = "https://raw.githubusercontent.com/26zl" $repo_name = "PowerShellPerfect" # Cache directory outside Documents (avoids Controlled Folder Access / ransomware protection blocks) $cacheDir = Join-Path $env:LOCALAPPDATA "PowerShellProfile" -if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } +if (-not (Test-Path $cacheDir)) { + # A failure here (locked/redirected LOCALAPPDATA, ACLs) must not abort the whole profile load and leave + # the user without a shell - the cache is best-effort; downstream callers already Test-Path before use. + try { New-Item -ItemType Directory -Path $cacheDir -Force -ErrorAction Stop | Out-Null } + catch { Write-Warning "Could not create cache dir '$cacheDir': $($_.Exception.Message)" } +} # JSONC comment-stripping regex (built via variable to avoid PS5 parser bug with [^"] in strings) $_q = [char]34 $jsoncCommentPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*`$" -# Opt-out of telemetry if running as admin (only set once) +# Admin check (used by prompt suffix, firewall helpers, Get-SystemInfo, Invoke-ProfileWizard). +# A profile must not silently mutate machine-scope env vars; telemetry opt-out is handled by +# setup.ps1 with explicit user consent. Uninstall-Profile Phase 6 still cleans up legacy values. $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -if ($isAdmin -and -not [System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine')) { - [System.Environment]::SetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'true', [System.EnvironmentVariableTarget]::Machine) -} + +# Core Windows processes whose termination logs the user out or bluescreens. The process-killing commands +# (pkill, Stop-StuckProcess) skip these by name and skip PID <= 4, so a careless kill can never take the +# session or the OS down. Single source of truth so the two killers can't drift. +$script:ProtectedProcessNames = @('System', 'Idle', 'Registry', 'csrss', 'wininit', 'winlogon', 'services', 'lsass', 'smss', 'svchost', 'dwm') # Canonical tool list - single source of truth for install, upgrade, cache invalidation, and version tracking. # Cache: init-script filename in $cacheDir that must be deleted when the tool is upgraded (or $null). @@ -46,6 +62,96 @@ $script:ProfileTools = @( @{ Name = "ripgrep"; Id = "BurntSushi.ripgrep.MSVC"; Cmd = "rg"; Cache = $null; VerCmd = "--version"; UpgradeStrategy = "winget" } ) +# Extensibility core. $PSP is the public namespace plugins, profile_user.ps1, and user-settings.json extend. +# Hooks: OnProfileLoad fires once after all load steps; PrePrompt fires before every prompt; OnCd fires when $pwd changes. +# Features: toggles heavy/optional behavior. Commands: registry consumed by Get-ProfileCommand and Show-Help. +$script:PSP = @{ + Hooks = @{ + OnProfileLoad = [System.Collections.Generic.List[scriptblock]]::new() + PrePrompt = [System.Collections.Generic.List[scriptblock]]::new() + OnCd = [System.Collections.Generic.List[scriptblock]]::new() + } + HelpSections = [System.Collections.Generic.List[object]]::new() + Commands = [System.Collections.Generic.List[object]]::new() + Features = @{ + psfzf = $true + predictions = $true + startupMessage = $true + perDirProfiles = $true + # transientPrompt collapses the previous prompt to a minimal form on Enter (p10k-style). + # Requires a console host and PSReadLine; no-op in CI/non-interactive. + transientPrompt = $false + # commandOverrides is a code-execution surface (JSON strings compiled to scriptblocks). + # Default off so a user-settings.json edit cannot silently redefine commands; users must + # opt in explicitly by setting features.commandOverrides = true. + commandOverrides = $false + # updateCheck hits the GitHub commits API at most once every 7 days (cached timestamp) + # to notify when main has advanced past the applied version. Default off so users + # running `irm | iex` inside scripts don't trigger a surprise network call per shell. + updateCheck = $false + } + # Scriptblock that returns the collapsed prompt string used when features.transientPrompt + # is enabled. Override in profile_user.ps1 to customize. Return value is printed verbatim. + TransientPrompt = { "$ " } + TrustedDirs = [System.Collections.Generic.List[string]]::new() + LastPwd = $null + # Directory history stack - populated by Invoke-PromptStage on cd. Most-recent first. + PwdHistory = [System.Collections.Generic.List[string]]::new() + PwdHistoryMax = 20 + # When set to $true, the next Invoke-PromptStage call skips pushing LastPwd onto + # PwdHistory. Used by cdb so a stack-pop navigation does not re-push the directory + # being consumed (which would create back-and-forth history loops on repeat cdb). + SuppressPwdHistoryPush = $false + # Tab-title base (without context prefix). Set at profile load; used by the PrePrompt + # hook that prepends venv/aws/k8s/jobs indicators. + BaseTitle = $null +} + +# Fire all scriptblocks registered for a hook event. Errors are isolated per hook. +function Invoke-ProfileHook { + [CmdletBinding()] + param([Parameter(Mandatory)][ValidateSet('OnProfileLoad', 'PrePrompt', 'OnCd')][string]$EventName) + if (-not $script:PSP -or -not $script:PSP.Hooks.ContainsKey($EventName)) { return } + foreach ($h in $script:PSP.Hooks[$EventName]) { + try { & $h } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning "Hook '$EventName' failed: $($_.Exception.Message)" + } + } +} + +# Register a scriptblock to run on a profile lifecycle event (OnProfileLoad | PrePrompt | OnCd). +function Register-ProfileHook { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('OnProfileLoad', 'PrePrompt', 'OnCd')][string]$EventName, + [Parameter(Mandatory)][scriptblock]$Action + ) + $script:PSP.Hooks[$EventName].Add($Action) +} + +# Add a section to Show-Help output. Use from plugins or profile_user.ps1 to advertise custom commands. +function Register-HelpSection { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string[]]$Lines + ) + $script:PSP.HelpSections.Add([PSCustomObject]@{ Title = $Title; Lines = $Lines }) +} + +# Add a command to the discovery registry consumed by Get-ProfileCommand. +function Register-ProfileCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Category, + [string]$Synopsis = '' + ) + $script:PSP.Commands.Add([PSCustomObject]@{ Name = $Name; Category = $Category; Synopsis = $Synopsis }) +} + # Run a scriptblock in a job with timeout; returns result or $null on timeout/failure. # Used for native init commands where we want an explicit timeout but can pass a resolved exe path. function Invoke-WithTimeout { @@ -85,7 +191,7 @@ function Invoke-DownloadWithRetry { for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { Remove-Item $OutFile -Force -ErrorAction SilentlyContinue - Invoke-RestMethod -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -ErrorAction Stop + Invoke-RestMethod -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop if (-not (Test-Path $OutFile) -or (Get-Item $OutFile).Length -eq 0) { Remove-Item $OutFile -Force -ErrorAction SilentlyContinue throw 'Downloaded file is missing or empty' @@ -94,7 +200,7 @@ function Invoke-DownloadWithRetry { } catch { if ($attempt -lt $MaxAttempts) { - Write-Warning "Download failed (attempt $attempt/$MaxAttempts): $_ Retrying in ${BackoffSec}s..." + Write-Host " Download failed (attempt $attempt/$MaxAttempts): $_ Retrying in ${BackoffSec}s..." -ForegroundColor Yellow Start-Sleep -Seconds $BackoffSec } else { @@ -104,6 +210,63 @@ function Invoke-DownloadWithRetry { } } +# Quote an argument array into a single command line for Start-Process -ArgumentList. Start-Process does NOT +# quote array elements that contain spaces, so a path like %USERPROFILE%\John Doe\... silently splits into two args. +# Returns a single string with whitespace/quote-bearing elements double-quoted (embedded quotes backslash-escaped). +function ConvertTo-NativeArgumentLine { + param([Parameter(Mandatory)][AllowEmptyCollection()][string[]]$ArgumentList) + ($ArgumentList | ForEach-Object { + if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } + }) -join ' ' +} + +# Read a UTF-8 JSON/text file the same way the profile WRITES them ([IO.File]::WriteAllText with UTF8-no-BOM). +# Get-Content -Raw without -Encoding decodes as the system ANSI codepage on Windows PowerShell 5.1, so a +# read-modify-write round trip (WT settings.json, user-settings.json) permanently corrupts non-ASCII bytes +# (accented usernames in paths, Unicode titles, emoji). [IO.File]::ReadAllText defaults to UTF-8 with BOM +# detection on both editions, making the read symmetric with the write. Throws on missing/locked file like +# Get-Content -Raw -ErrorAction Stop, so callers keep their existing try/catch behavior. +function Get-Utf8FileText { + param([Parameter(Mandatory)][string]$Path) + [System.IO.File]::ReadAllText($Path) +} + +# Write UTF-8 (no BOM) via a sibling temp file, then swap it into place with Move-Item -Force. A plain +# [IO.File]::WriteAllText truncates the target in place, so a crash or a second shell writing concurrently can +# leave a half-written / empty settings.json. Writing to temp first means the target is only ever replaced by +# a fully-formed file. Throws on failure like the WriteAllText it replaces, so callers keep their existing +# try/catch and rolling-backup behavior. +# ponytail: Move-Item -Force is remove-then-rename, not a true atomic swap - a crash in that microsecond +# window can leave only the temp file; the rolling .bak the callers already take covers that residual. Use +# [IO.File]::Replace if a zero-window swap is ever needed and PS5.1/NTFS is guaranteed. +function Write-Utf8FileAtomic { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Content) + $dir = Split-Path -Parent $Path + if (-not $dir) { $dir = '.' } + $tmp = Join-Path $dir ('.psp-tmp-' + [System.IO.Path]::GetRandomFileName()) + try { + [System.IO.File]::WriteAllText($tmp, $Content, [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $tmp -Destination $Path -Force + } + catch { + if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } + throw + } +} + +# Latest commit SHA on the upstream main branch via the GitHub API, or $null on any failure (best-effort). +# Single source for Update-Profile's baseline refresh and the opt-in startup update-check, so the owner +# derivation and API URL can't drift between the two. +function Get-LatestMainCommitSha { + try { + $owner = ($repo_root -replace '^https?://(raw\.)?githubusercontent\.com/', '').Trim('/') + $resp = Invoke-RestMethod -Uri "https://api.github.com/repos/$owner/$repo_name/commits/main" -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop + if ($resp -and $resp.sha) { return $resp.sha } + } + catch { $null = $_ } + return $null +} + # Get the full path to an external command function Get-ExternalCommandPath { param( @@ -119,8 +282,8 @@ function Get-ExternalCommandPath { } $pathCandidates = @($cmd.Path, $cmd.Source, $cmd.Definition) | - Where-Object { $_ -and [System.IO.Path]::IsPathRooted([string]$_) } | - Select-Object -Unique + Where-Object { $_ -and [System.IO.Path]::IsPathRooted([string]$_) } | + Select-Object -Unique foreach ($pathCandidate in $pathCandidates) { if (Test-Path -LiteralPath $pathCandidate -PathType Leaf) { return $pathCandidate @@ -130,6 +293,60 @@ function Get-ExternalCommandPath { return $null } +function Update-SessionPathFromRegistry { + $machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + $userPath = [System.Environment]::GetEnvironmentVariable('PATH', 'User') + $env:PATH = (@($machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' +} + +# Tab-title helpers used by long-running wrappers (ssh/dex/dlogs/serve/watch/journal -Follow) +# to make it obvious what each tab is doing. Push returns the prior title so Pop can restore +# it; both are silent on terminals that don't support title setting. LIFO-safe (nest freely). +function Push-TabTitle { + param([Parameter(Mandatory)][string]$Title) + $old = $null + try { $old = $Host.UI.RawUI.WindowTitle } catch { $null = $_ } + try { $Host.UI.RawUI.WindowTitle = $Title } catch { $null = $_ } + return $old +} + +function Pop-TabTitle { + param([AllowNull()][string]$OldTitle) + if ($null -eq $OldTitle) { return } + try { $Host.UI.RawUI.WindowTitle = $OldTitle } catch { $null = $_ } +} + +# Resolve the active Windows Terminal settings.json across install variants: Store, Preview, +# Canary, and unpackaged (GitHub zip). Returns the first existing path, or $null if WT is not +# installed. Callers that WRITE settings should use Get-WindowsTerminalSettingsPaths (plural) +# instead so all installed variants stay in sync; the singular helper is kept for single-variant +# reads (e.g. Uninstall-Profile restore picks the most-precedence variant's backup). +function Get-WindowsTerminalSettingsPath { + $candidates = @( + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalCanary_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\settings.json' + ) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + return $null +} + +# Resolve ALL existing Windows Terminal settings.json files across installed variants so writers +# can update every one a user has. Returns an array (possibly empty). Fixes the "wrote to Stable +# but Preview was the active terminal" class of bugs. +function Get-WindowsTerminalSettingsPaths { + $candidates = @( + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalCanary_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\settings.json' + ) + @($candidates | Where-Object { Test-Path -LiteralPath $_ }) +} + # Merge PSCustomObject overrides recursively so nested user/theme/terminal keys are preserved. function Merge-JsonObject { param( @@ -137,7 +354,8 @@ function Merge-JsonObject { $override ) - if (-not $base -or -not $override) { return } + if ($null -eq $override) { return } + if ($null -eq $base) { throw 'Merge-JsonObject: $base cannot be null (caller must pass an object to merge into).' } foreach ($prop in $override.PSObject.Properties) { $baseVal = $base.PSObject.Properties[$prop.Name] if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) { @@ -174,7 +392,7 @@ function Get-OhMyPoshExecutablePath { $candidateDir = Split-Path -Path $candidatePath -Parent $pathEntries = @($env:PATH -split ';' | Where-Object { $_ }) if ($pathEntries -notcontains $candidateDir) { - $env:PATH = $candidateDir + ';' + $env:PATH + $env:PATH = (@($candidateDir, $env:PATH) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' } return $candidatePath @@ -230,7 +448,7 @@ function Get-OhMyPoshMsiProductCode { ) $entries = Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -eq 'Oh My Posh' } + Where-Object { $_.DisplayName -eq 'Oh My Posh' } foreach ($entry in $entries) { foreach ($uninstallString in @($entry.QuietUninstallString, $entry.UninstallString)) { if ($uninstallString -and $uninstallString -match '\{[0-9A-Fa-f\-]{36}\}') { @@ -275,8 +493,8 @@ function Get-ProfileToolVersionText { try { $versionLine = & $ExecutablePath @versionArgs 2>$null | - Where-Object { $_ -match '\d+\.\d+' } | - Select-Object -First 1 + Where-Object { $_ -match '\d+\.\d+' } | + Select-Object -First 1 if ($versionLine) { return $versionLine.Trim() } @@ -546,9 +764,12 @@ function Update-Profile { [switch]$Force ) - $tempProfile = Join-Path $env:TEMP "Microsoft.PowerShell_profile.ps1" - $tempConfig = Join-Path $env:TEMP "theme.json" - $tempTerminalConfig = Join-Path $env:TEMP "terminal-config.json" + # Use randomized tempfile names so concurrent Update-Profile runs (and other processes + # writing fixed-name files under %TEMP%) can't race or clobber each other. + $tempSuffix = [System.IO.Path]::GetRandomFileName() + $tempProfile = Join-Path $env:TEMP "psp-profile-$tempSuffix.ps1" + $tempConfig = Join-Path $env:TEMP "psp-theme-$tempSuffix.json" + $tempTerminalConfig = Join-Path $env:TEMP "psp-terminal-$tempSuffix.json" $userSettingsPath = Join-Path $cacheDir "user-settings.json" $userSettingsStatePath = Join-Path $cacheDir "user-settings.applied.sha256" @@ -561,6 +782,13 @@ function Update-Profile { $userWindowsTerminalOverridePresent = $false $userTerminalDefaultsOverridePresent = $false $userKeybindingsOverridePresent = $false + + # Gate the network phase behind ShouldProcess so -WhatIf does not pull files from GitHub. + # Descriptive action label covers the entire download+hash+copy flow because we cannot + # know what will change without first downloading. + $updateSource = "$repo_root/$repo_name/main" + if (-not $PSCmdlet.ShouldProcess($updateSource, 'Download profile/theme/terminal-config and apply updates')) { return } + try { # Phase 1: Download profile and config $profileUrl = "$repo_root/$repo_name/main/Microsoft.PowerShell_profile.ps1" @@ -588,10 +816,32 @@ function Update-Profile { $phaseErrors += "terminal-config.json download: $_" } - # Phase 2: Hash verification (profile .ps1 only) - $oldHash = if (Test-Path $PROFILE) { (Get-FileHash -Path $PROFILE -Algorithm SHA256).Hash } else { "" } + # Phase 2: Hash verification (profile .ps1 only). + # Check BOTH edition dirs, not just $PROFILE: a user running Update-Profile from PS7 + # when PS5 has a stale profile (or vice versa) needs the other edition resynced too. $newHash = (Get-FileHash -Path $tempProfile -Algorithm SHA256).Hash - $profileChanged = $newHash -ne $oldHash + $_docsRoot = Split-Path (Split-Path $PROFILE) + $_editionDirs = @( + (Join-Path $_docsRoot 'PowerShell') + (Join-Path $_docsRoot 'WindowsPowerShell') + ) + $_installedProfiles = foreach ($_ed in $_editionDirs) { + $_p = Join-Path $_ed 'Microsoft.PowerShell_profile.ps1' + if (Test-Path $_p) { $_p } + } + $profileChanged = $false + if (-not $_installedProfiles) { + # Fresh install scenario - always copy + $profileChanged = $true + } + else { + foreach ($_ip in $_installedProfiles) { + if ((Get-FileHash -Path $_ip -Algorithm SHA256).Hash -ne $newHash) { + $profileChanged = $true + break + } + } + } # Check if config actually changed $configChanged = $false @@ -649,7 +899,7 @@ function Update-Profile { finally { $sha.Dispose() } if (-not $ExpectedSha256) { - Write-Host "Downloaded file hashes:" -ForegroundColor Yellow + Write-Host "Downloaded file hashes (computed over what was just fetched):" -ForegroundColor Yellow Write-Host " profile.ps1: $newHash" -ForegroundColor Yellow if ($configDownloaded) { Write-Host " theme.json: $newConfigHash" -ForegroundColor Yellow @@ -664,8 +914,10 @@ function Update-Profile { Write-Host " terminal-config: (not downloaded)" -ForegroundColor Yellow } Write-Host " combined: $combinedHash" -ForegroundColor Yellow - Write-Host "Verify at https://github.com/26zl/PowerShellPerfect" -ForegroundColor Yellow - throw "Hash verification required. Re-run with -ExpectedSha256 '$combinedHash' or -SkipHashCheck." + Write-Host "These hashes confirm FILE INTEGRITY of the current download (no truncation, no corruption)." -ForegroundColor DarkYellow + Write-Host "To pin against a specific upstream commit, verify the SHA out-of-band first:" -ForegroundColor DarkYellow + Write-Host " https://github.com/26zl/PowerShellPerfect/commits/main" -ForegroundColor DarkYellow + throw "Hash input required. Re-run with -ExpectedSha256 '$combinedHash' (reproducible install) or -SkipHashCheck." } $expected = $ExpectedSha256.ToUpperInvariant() if ($combinedHash -ne $expected) { @@ -673,7 +925,11 @@ function Update-Profile { } } - # Phase 3: Copy profile to PS5/PS7 dirs (only if changed) + # Phase 3: Copy profile to PS5/PS7 dirs (only if changed). + # On a fresh install the current edition's profile dir may not exist yet; create it for + # the edition that is actually running so the copy lands. We still avoid creating the + # OTHER edition's dir (if the user does not have that edition installed we have no business + # putting a profile there). if ($profileChanged) { if ($PSCmdlet.ShouldProcess($PROFILE, "Replace profile with downloaded version (hash: $newHash)")) { $docsRoot = Split-Path (Split-Path $PROFILE) @@ -681,6 +937,16 @@ function Update-Profile { Join-Path $docsRoot "PowerShell" Join-Path $docsRoot "WindowsPowerShell" ) + $currentEditionDir = Split-Path $PROFILE + if (-not (Test-Path $currentEditionDir)) { + try { + New-Item -ItemType Directory -Path $currentEditionDir -Force | Out-Null + Write-Host "Created profile directory: $currentEditionDir" -ForegroundColor DarkGray + } + catch { + Write-Warning "Failed to create profile directory $currentEditionDir`: $_" + } + } $copySuccess = 0 $copyFailed = @() foreach ($dir in $profileDirs) { @@ -744,7 +1010,7 @@ function Update-Profile { # Apply user-settings.json overrides (never downloaded, never overwritten) if (Test-Path $userSettingsPath) { try { - $userSettings = Get-Content $userSettingsPath -Raw | ConvertFrom-Json + $userSettings = Get-Utf8FileText $userSettingsPath | ConvertFrom-Json $userSettingsParsed = $true $userThemeOverridePresent = $null -ne $userSettings.theme $userWindowsTerminalOverridePresent = $null -ne $userSettings.windowsTerminal @@ -781,15 +1047,37 @@ function Update-Profile { } } else { - # Create starter template so users know the file exists + # Create starter template so users know the file exists. Must match the template + # created by setup.ps1 (around line 892) so users see the same override surface + # regardless of which command created the file. $userSettingsTemplate = @' { - "_comment": "User overrides for terminal and theme settings. Only add keys you want to override.", + "_comment": "User overrides for terminal, theme, and profile behavior. Only add keys you want to override.", "_examples": { "theme": { "name": "catppuccin", "url": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/catppuccin.omp.json" }, "windowsTerminal": { "colorScheme": "One Half Dark", "cursorColor": "#ffffff" }, - "defaults": { "opacity": 90, "font": { "size": 14 } }, - "keybindings": [{ "keys": "ctrl+shift+t", "command": { "action": "newTab" } }] + "defaults": { + "opacity": 90, + "font": { "size": 14 }, + "backgroundImage": "%USERPROFILE%\\Pictures\\bg.png", + "backgroundImageOpacity": 0.3, + "backgroundImageStretchMode": "uniformToFill", + "backgroundImageAlignment": "center" + }, + "keybindings": [{ "keys": "ctrl+shift+t", "command": { "action": "newTab" } }], + "features": { + "psfzf": true, + "predictions": true, + "startupMessage": true, + "perDirProfiles": true, + "_commandOverrides_note": "set commandOverrides to true ONLY if you also populate the commandOverrides section below. Default off because JSON strings get compiled to scriptblocks at profile load.", + "commandOverrides": false + }, + "commandOverrides": { + "_note": "entries here are ignored unless features.commandOverrides = true", + "gs": "git status --short" + }, + "trustedDirs": [] } } '@ @@ -887,11 +1175,12 @@ function Update-Profile { } } - # Phase 6: Windows Terminal sync + # Phase 6: Windows Terminal sync - iterate ALL installed WT variants so users running + # Stable + Preview (or Canary) get every variant updated, not just the first found. $terminalOverridesChanged = $userSettingsChanged -and ($userWindowsTerminalOverridePresent -or $userTerminalDefaultsOverridePresent -or $userKeybindingsOverridePresent) if (($Force -or $profileChanged -or $configChanged -or $terminalConfigChanged -or $terminalOverridesChanged) -and (($config -and $config.windowsTerminal) -or $terminalConfig)) { - $wtSettingsPath = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json" - if (Test-Path $wtSettingsPath) { + $wtSettingsPaths = Get-WindowsTerminalSettingsPaths + foreach ($wtSettingsPath in $wtSettingsPaths) { if ($PSCmdlet.ShouldProcess($wtSettingsPath, "Update Windows Terminal settings")) { try { $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" @@ -911,7 +1200,7 @@ function Update-Profile { $wt = $null for ($wtAttempt = 1; $wtAttempt -le 2; $wtAttempt++) { try { - $wtRaw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncCommentPattern, '' + $wtRaw = (Get-Utf8FileText $wtSettingsPath) -replace $jsoncCommentPattern, '' $wt = $wtRaw | ConvertFrom-Json break } @@ -960,6 +1249,21 @@ function Update-Profile { $schemeDefName = if ($schemeDef.name) { $schemeDef.name } else { $schemeName } $wt.schemes = @(@($wt.schemes | Where-Object { $_ -and $_.name -ne $schemeDefName }) + ([PSCustomObject]$schemeDef)) } + + # Upsert custom WT theme (tab bar colors, window chrome) from theme.json + $themeDef = $config.windowsTerminal.themeDefinition + $themeActive = $config.windowsTerminal.theme + if ($themeDef) { + if (-not $wt.themes) { + $wt | Add-Member -NotePropertyName "themes" -NotePropertyValue @() -Force + } + $themeDefName = $themeDef.name + $wt.themes = @(@($wt.themes | Where-Object { $_ -and $_.name -ne $themeDefName }) + ([PSCustomObject]$themeDef)) + } + if ($themeActive) { + if ($wt.PSObject.Properties['theme']) { $wt.theme = $themeActive } + else { $wt | Add-Member -NotePropertyName "theme" -NotePropertyValue $themeActive -Force } + } } # Keybindings last @@ -1009,9 +1313,11 @@ function Update-Profile { } } - $wtJson = $wt | ConvertTo-Json -Depth 10 + # Depth 100: WT settings can have deeply nested action/command objects; + # depth 10 silently truncates those to their type name string and corrupts settings. + $wtJson = $wt | ConvertTo-Json -Depth 100 $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($wtSettingsPath, $wtJson, $utf8NoBom) + Write-Utf8FileAtomic $wtSettingsPath $wtJson Write-Host "Windows Terminal settings updated." -ForegroundColor Green } catch { @@ -1043,7 +1349,7 @@ function Update-Profile { } # Refresh PATH so newly installed tools are found if ($installedTools.Count -gt 0) { - $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + Update-SessionPathFromRegistry } # PSFzf module (required for fzf integration) if ((Get-Command fzf -ErrorAction SilentlyContinue) -and -not (Get-Module -ListAvailable -Name PSFzf)) { @@ -1098,8 +1404,26 @@ function Update-Profile { } } + # Refresh the applied-commit baseline used by the opt-in update-check so it starts + # from the freshly-pulled version; best-effort - a network hiccup here is harmless. if ($profileActuallyUpdated) { - Restart-TerminalToApply -Message "Profile updated. Restarting terminal..." + $_upSha = Get-LatestMainCommitSha + if ($_upSha) { + try { Write-Utf8FileAtomic (Join-Path $cacheDir 'applied-commit.sha') $_upSha } catch { $null = $_ } + } + } + + # Restart the terminal whenever *anything* that the running session would load + # differently has changed - not just the profile.ps1 itself. Without this, changes + # to theme.json / terminal-config.json / user-settings.json land on disk but the + # current shell still shows old prompt/features until the user manually reloads. + $anyRuntimeChange = $profileActuallyUpdated -or $configChanged -or $terminalConfigChanged -or $userSettingsChanged + if ($anyRuntimeChange) { + $reason = if ($profileActuallyUpdated) { 'Profile updated' } + elseif ($configChanged) { 'Theme config updated' } + elseif ($terminalConfigChanged) { 'Terminal config updated' } + else { 'User settings updated' } + Restart-TerminalToApply -Message "$reason. Restarting terminal..." } } catch { @@ -1126,7 +1450,7 @@ function Update-PowerShell { $gitHubApiUrl = "https://api.github.com/repos/PowerShell/PowerShell/releases/latest" $headers = @{} if ($env:GITHUB_TOKEN) { $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" } - $latestReleaseInfo = Invoke-RestMethod -Uri $gitHubApiUrl -TimeoutSec 10 -Headers $headers + $latestReleaseInfo = Invoke-RestMethod -Uri $gitHubApiUrl -TimeoutSec 10 -Headers $headers -UseBasicParsing if (-not $latestReleaseInfo.tag_name) { Write-Error "Invalid GitHub API response (missing tag_name)."; return } $latestVersionStr = $latestReleaseInfo.tag_name.Trim('v') -replace '-.*$', '' $latestVersion = [version]$latestVersionStr @@ -1195,7 +1519,7 @@ function Update-Tools { winget upgrade --id $tool.Id --accept-source-agreements --accept-package-agreements if ($LASTEXITCODE -eq 0) { # Refresh PATH so the new binary is found for version check - $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + Update-SessionPathFromRegistry $newToolPath = Get-ProfileToolExecutablePath -Tool $tool $newVer = if ($newToolPath) { Get-ProfileToolVersionText -Tool $tool -ExecutablePath $newToolPath } else { $null } if ($newVer -and $oldVer -and $newVer -ne $oldVer) { @@ -1262,18 +1586,37 @@ function Clear-Cache { Write-Host "Clearing cache..." -ForegroundColor Cyan - $targets = @( - @{ Name = "User Temp"; Path = "$env:TEMP\*"; Recurse = $true }, - @{ Name = "Internet Explorer Cache"; Path = "$env:LOCALAPPDATA\Microsoft\Windows\INetCache\*"; Recurse = $true } - ) + # Build from validated base dirs: if an env var is ever empty/unset, "$env:X\*" would expand to "\*" and + # recurse-delete the current drive root. Skip any base that is empty or doesn't exist; Join-Path the glob. + $bases = @() + if (-not [string]::IsNullOrWhiteSpace($env:TEMP)) { $bases += @{ Name = "User Temp"; Base = $env:TEMP; Recurse = $true } } + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { $bases += @{ Name = "Internet Explorer Cache"; Base = (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\INetCache'); Recurse = $true } } if ($IncludeSystemCaches) { - $targets += @( - @{ Name = "Windows Temp"; Path = "$env:SystemRoot\Temp\*"; Recurse = $true }, - @{ Name = "Windows Prefetch"; Path = "$env:SystemRoot\Prefetch\*"; Recurse = $false } - ) + # System paths affect every user on the box and require admin. SupportsShouldProcess + # defaults to ConfirmImpact=Medium and $ConfirmPreference is High by default, so + # -IncludeSystemCaches without -Confirm would otherwise delete silently. Require an + # explicit y/N prompt (unless -Confirm:$false or -WhatIf was passed). + if (-not $WhatIfPreference -and $ConfirmPreference -ne 'None') { + Write-Host '' + Write-Host 'You are about to clear SYSTEM caches (Windows\Temp, Windows\Prefetch).' -ForegroundColor Yellow + Write-Host 'These paths affect every user on this machine and require admin.' -ForegroundColor Yellow + $reply = Read-Host ' Continue? [y/N]' + if ($reply -notmatch '^(?i:y|yes)$') { + Write-Host 'Cancelled. User caches left untouched as well.' -ForegroundColor DarkGray + return + } + } + if (-not [string]::IsNullOrWhiteSpace($env:SystemRoot)) { + $bases += @{ Name = "Windows Temp"; Base = (Join-Path $env:SystemRoot 'Temp'); Recurse = $true } + $bases += @{ Name = "Windows Prefetch"; Base = (Join-Path $env:SystemRoot 'Prefetch'); Recurse = $false } + } } + $targets = @($bases | + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Base) -and (Test-Path -LiteralPath $_.Base) } | + ForEach-Object { @{ Name = $_.Name; Path = (Join-Path $_.Base '*'); Recurse = $_.Recurse } }) + foreach ($target in $targets) { if ($PSCmdlet.ShouldProcess($target.Path, "Clear $($target.Name)")) { Write-Host "Clearing $($target.Name)..." -ForegroundColor Yellow @@ -1288,10 +1631,115 @@ function Clear-Cache { } } +# Show the execution time of the last command (fish-style `cmd_duration` / starship duration). +# Reads $MaximumHistoryCount-bounded Get-History so it works even with large histories. +function duration { + $last = Get-History -Count 1 -ErrorAction SilentlyContinue + if (-not $last) { + Write-Host 'No history yet.' -ForegroundColor Yellow + return + } + $span = $last.EndExecutionTime - $last.StartExecutionTime + $cmd = $last.CommandLine + if ($cmd.Length -gt 60) { $cmd = $cmd.Substring(0, 57) + '...' } + $secs = [math]::Round($span.TotalSeconds, 3) + Write-Host (" {0}" -f $cmd) -ForegroundColor DarkGray + Write-Host (" {0}s ({1:hh\:mm\:ss\.fff})" -f $secs, $span) -ForegroundColor Cyan +} + +# Jump back N directories in the cd history stack. Default N=1 (previous directory). +# The stack is maintained by Invoke-PromptStage and bounded to $PSP.PwdHistoryMax. +# Consumes entries 1..N from the stack (so repeat cdb walks further back instead of +# oscillating) and sets SuppressPwdHistoryPush so the Set-Location doesn't cause the +# prompt hook to re-push the departure. +function cdb { + [CmdletBinding()] + param([int]$N = 1) + if (-not $script:PSP -or -not $script:PSP.PwdHistory -or $script:PSP.PwdHistory.Count -eq 0) { + Write-Host 'No directory history yet.' -ForegroundColor Yellow + return + } + if ($N -lt 1 -or $N -gt $script:PSP.PwdHistory.Count) { + Write-Host ("History has {0} entries; N must be 1..{0}." -f $script:PSP.PwdHistory.Count) -ForegroundColor Yellow + return + } + $target = $script:PSP.PwdHistory[$N - 1] + if (-not (Test-Path -LiteralPath $target)) { + Write-Warning "Directory no longer exists: $target (marked with '!' in cdh). Run 'cdh' to see the stack." + return + } + # Pop the consumed entries (0..N-1) so the stack reflects where we actually are. + for ($i = 0; $i -lt $N; $i++) { $script:PSP.PwdHistory.RemoveAt(0) } + # Suppress auto-push on the Set-Location that follows - we already cleaned up the stack. + $script:PSP.SuppressPwdHistoryPush = $true + Set-Location -LiteralPath $target +} + +# List the cd history stack (most-recent first). `cdb N` jumps to entry [N]. +function cdh { + if (-not $script:PSP -or -not $script:PSP.PwdHistory -or $script:PSP.PwdHistory.Count -eq 0) { + Write-Host 'No directory history yet.' -ForegroundColor Yellow + return + } + $i = 1 + foreach ($d in $script:PSP.PwdHistory) { + $exists = Test-Path -LiteralPath $d + $color = if ($exists) { 'White' } else { 'DarkGray' } + $mark = if ($exists) { ' ' } else { '!' } + Write-Host (" [{0}]{1} {2}" -f $i, $mark, $d) -ForegroundColor $color + $i++ + } +} + # Admin Check and Prompt Customization (fallback when Oh My Posh is not loaded) $adminSuffix = if ($isAdmin) { " [ADMIN]" } else { "" } # PowerShell prompt (fallback when Oh My Posh is not loaded) +# Shared prompt-stage helper: fires PrePrompt hooks, detects cd, fires OnCd, and auto-loads +# trusted .psprc.ps1 when the directory changes. Called from both fallback and OMP prompts. +function Invoke-PromptStage { + if (-not $script:PSP) { return } + Invoke-ProfileHook -EventName 'PrePrompt' + try { + $current = $PWD.ProviderPath + if (-not $current) { return } + if ($current -eq $script:PSP.LastPwd) { return } + # Push previous pwd onto the history stack (most-recent first, bounded). + # Explicit null check: empty List is falsy under -and in PowerShell. + # `cdb` sets SuppressPwdHistoryPush so its stack-pop navigation doesn't re-push the + # just-consumed entry (which would make repeat cdb oscillate instead of walk back). + if ($script:PSP.SuppressPwdHistoryPush) { + $script:PSP.SuppressPwdHistoryPush = $false + } + elseif ($script:PSP.LastPwd -and $null -ne $script:PSP.PwdHistory) { + $script:PSP.PwdHistory.Insert(0, $script:PSP.LastPwd) + while ($script:PSP.PwdHistory.Count -gt $script:PSP.PwdHistoryMax) { + $script:PSP.PwdHistory.RemoveAt($script:PSP.PwdHistory.Count - 1) + } + } + $script:PSP.LastPwd = $current + Invoke-ProfileHook -EventName 'OnCd' + if (-not $script:PSP.Features.perDirProfiles) { return } + $rc = Join-Path $current '.psprc.ps1' + if (-not (Test-Path -LiteralPath $rc)) { return } + if ($script:PSP.TrustedDirs -contains $current) { + try { . $rc } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning ".psprc.ps1 failed: $($_.Exception.Message)" + } + } + else { + Write-Host ".psprc.ps1 found in this directory. Run Add-TrustedDirectory to auto-load it." -ForegroundColor Yellow + } + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning "prompt stage: $($_.Exception.Message)" + } +} + function prompt { + Invoke-PromptStage if ($adminSuffix) { "[" + (Get-Location) + "] # " } else { "[" + (Get-Location) + "] $ " } } $script:FallbackPromptFunction = $Function:prompt @@ -1303,6 +1751,8 @@ if ($null -eq $script:EditorPriority) { $script:EditorPriority = @('code', 'notepad') } $script:ResolvedEditor = $null +# Default args carried by the resolved editor (e.g. EDITOR='code --wait' -> @('--wait')). edit/ep/hosts prepend these. +$script:ResolvedEditorArgs = @() # Resolve preferred editor from EditorPriority or env EDITOR (used by edit/Edit-Profile) function Resolve-PreferredEditor { @@ -1315,20 +1765,32 @@ function Resolve-PreferredEditor { $candidates += @($script:EditorPriority) foreach ($candidate in ($candidates | Where-Object { $_ })) { - if (Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue) { - $script:ResolvedEditor = $candidate + # A candidate may carry flags, e.g. EDITOR='code --wait'. Resolve the executable; if the whole string + # is not itself a command and contains whitespace, treat the first token as the exe and the rest as + # default args. (An exe path that itself contains spaces should be selected via $script:EditorPriority.) + $exe = $candidate + $extraArgs = @() + if (-not (Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue) -and $candidate -match '\s') { + $tok = $candidate -split '\s+' + $exe = $tok[0] + $extraArgs = @($tok | Select-Object -Skip 1) + } + if (Get-Command $exe -CommandType Application -ErrorAction SilentlyContinue) { + $script:ResolvedEditor = $exe + $script:ResolvedEditorArgs = $extraArgs return $script:ResolvedEditor } } $script:ResolvedEditor = 'notepad' + $script:ResolvedEditorArgs = @() return $script:ResolvedEditor } # Open files with preferred editor (alias: edit) function edit { $editor = Resolve-PreferredEditor - & $editor @args + & $editor @script:ResolvedEditorArgs @args } # Quick Access to Editing the Profile @@ -1338,13 +1800,16 @@ function Edit-Profile { Set-Alias -Name ep -Value Edit-Profile # Create file or update its timestamp -function touch($file) { - if (-not $file) { Write-Error "Usage: touch "; return } - if (Test-Path -LiteralPath $file) { - (Get-Item -LiteralPath $file).LastWriteTime = Get-Date - } - else { - New-Item -ItemType File -Path $file -Force | Out-Null +function touch { + param([Parameter(ValueFromRemainingArguments)][string[]]$Files) + if (-not $Files) { Write-Error "Usage: touch [file2 ...]"; return } + foreach ($file in $Files) { + if (Test-Path -LiteralPath $file) { + (Get-Item -LiteralPath $file).LastWriteTime = Get-Date + } + else { + New-Item -ItemType File -Path $file -Force | Out-Null + } } } # Recursive file search by name @@ -1365,43 +1830,106 @@ function pubip { } } -# Open WinUtil full-release (downloads to temp file, then executes locally) +# Open WinUtil (Chris Titus) - safe-by-default: downloads a random tempfile, shows SHA256 + URL, +# and does NOT execute unless the caller opts in with -ExpectedSha256 (hash-pinned) or +# -Force (trust-on-download). Even then, execution is gated behind ShouldProcess so interactive +# users get a high-impact confirmation prompt and automation must opt out explicitly. +# Source: https://christitus.com/win (remote script, not hash-pinned by upstream). function winutil { - $scriptPath = Join-Path $env:TEMP "winutil.ps1" + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [ValidatePattern('^[A-Fa-f0-9]{64}$')] + [string]$ExpectedSha256, + [switch]$Force + ) + if ($ExpectedSha256 -and $Force) { + Write-Error 'Use either -ExpectedSha256 or -Force, not both.' + return + } + $scriptPath = Join-Path $env:TEMP ("winutil-" + [System.IO.Path]::GetRandomFileName() + ".ps1") try { - Invoke-RestMethod https://christitus.com/win -OutFile $scriptPath -TimeoutSec 10 -ErrorAction Stop - & $scriptPath + Invoke-RestMethod -Uri 'https://christitus.com/win' -OutFile $scriptPath -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop + if (-not (Test-Path $scriptPath) -or (Get-Item $scriptPath).Length -eq 0) { + throw 'Downloaded WinUtil script is empty.' + } + $actualHash = (Get-FileHash -LiteralPath $scriptPath -Algorithm SHA256).Hash + Write-Host "Source: https://christitus.com/win" + Write-Host "SHA256: $actualHash" -ForegroundColor Cyan + $downloadLabel = "WinUtil script from https://christitus.com/win (SHA256: $actualHash)" + if ($ExpectedSha256) { + if ($actualHash -ine $ExpectedSha256.Trim()) { + Write-Error "SHA256 mismatch. Expected: $ExpectedSha256. Actual: $actualHash. Aborting." + return + } + if (-not $PSCmdlet.ShouldProcess($downloadLabel, 'Execute downloaded WinUtil script (hash matched)')) { + return + } + Write-Host "SHA256 matched expected value. Executing..." -ForegroundColor Green + & $scriptPath + return + } + if ($Force) { + Write-Warning 'Executing an external script without hash pinning. Review the source and SHA256 first.' + if (-not $PSCmdlet.ShouldProcess($downloadLabel, 'Execute downloaded WinUtil script without hash verification')) { + return + } + Write-Host "Executing with -Force (no hash verification)..." -ForegroundColor Yellow + & $scriptPath + return + } + Write-Host '' + Write-Host "NOT executing by default. Re-run with one of:" -ForegroundColor Yellow + Write-Host " winutil -ExpectedSha256 '$actualHash' (pin this version)" + Write-Host " winutil -Force (trust without hash check)" } catch { - Write-Error "Failed to run WinUtil: $_" + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "Failed to fetch WinUtil: $($_.Exception.Message)" } finally { - Remove-Item $scriptPath -ErrorAction SilentlyContinue + Remove-Item $scriptPath -Force -ErrorAction SilentlyContinue } } -# Launch Harden Windows Security (hss.exe) if installed +# Launch Harden Windows Security (hss.exe) if installed. Even though this is a local binary, +# it is a system-hardening tool, so require an explicit ShouldProcess confirmation. function harden { - if (Get-Command "hss.exe" -ErrorAction SilentlyContinue) { - Start-Process "hss.exe" + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param() + $hssPath = Get-ExternalCommandPath -CommandName 'hss.exe' + if ($hssPath) { + if ($PSCmdlet.ShouldProcess($hssPath, 'Launch Harden Windows Security')) { + Start-Process $hssPath + } } else { Write-Warning "hss.exe not found. Install Harden Windows Security from: https://github.com/HotCakeX/Harden-Windows-Security" } } -# Open elevated Windows Terminal (detects PS edition) +# Open an elevated terminal. Prefers Windows Terminal (`wt`) so you get tabs/theming; +# falls back to a plain elevated pwsh/powershell window if wt isn't installed so the +# command works on vanilla Windows hosts too. function admin { $shell = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh.exe" } else { "powershell.exe" } + $hasWt = [bool](Get-Command wt -ErrorAction SilentlyContinue) if ($args.Count -gt 0) { $escaped = $args | ForEach-Object { if ($_ -match '\s') { "'$($_ -replace "'","''")'" } else { $_ } } $command = $escaped -join ' ' $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) - Start-Process wt -Verb runAs -ArgumentList "$shell -NoExit -EncodedCommand $encoded" + if ($hasWt) { + Start-Process wt -Verb runAs -ArgumentList "$shell -NoExit -EncodedCommand $encoded" + } + else { + Start-Process -FilePath $shell -Verb runAs -ArgumentList @('-NoExit', '-EncodedCommand', $encoded) + } } - else { + elseif ($hasWt) { Start-Process wt -Verb runAs } + else { + Start-Process -FilePath $shell -Verb runAs + } } Set-Alias -Name su -Value admin @@ -1493,7 +2021,7 @@ function hb { $uri = "https://bin.christitus.com/documents" try { - $response = Invoke-RestMethod -Uri $uri -Method Post -Body $Content -ErrorAction Stop -TimeoutSec 10 + $response = Invoke-RestMethod -Uri $uri -Method Post -Body $Content -ErrorAction Stop -TimeoutSec 10 -UseBasicParsing $hasteKey = $response.key $url = "https://bin.christitus.com/$hasteKey" Set-Clipboard $url @@ -1661,7 +2189,13 @@ function export($name, $value) { # Kill process by name function pkill($name) { if (-not $name) { Write-Error "Usage: pkill "; return } - Get-Process $name -ErrorAction SilentlyContinue | Stop-Process -ErrorAction SilentlyContinue + # Refuse a bare wildcard ('*', '?', '.*') that would match every process - `pkill *` must never nuke the + # whole session. Pass a specific name (wildcards within a name like 'chrome*' are still fine). + if ($name -match '^[\*\?\.\s]+$') { Write-Error "pkill: refusing to match every process. Pass a specific name."; return } + $procs = @(Get-Process $name -ErrorAction SilentlyContinue | + Where-Object { $_.Id -gt 4 -and $_.Id -ne $PID -and $script:ProtectedProcessNames -notcontains $_.ProcessName }) + if (-not $procs) { Write-Warning "pkill: no killable process matching '$name'."; return } + $procs | Stop-Process -ErrorAction SilentlyContinue } # List processes by name @@ -1708,6 +2242,7 @@ function trash($path) { } $shell = New-Object -ComObject 'Shell.Application' + $folder = $null; $shellItem = $null try { $folder = $shell.NameSpace($parentPath) if (-not $folder) { @@ -1723,7 +2258,10 @@ function trash($path) { Write-Host "Item '$($item.FullName)' has been moved to the Recycle Bin." } finally { - [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) + # Release every COM RCW (child first), not just $shell, so repeated trash calls don't leak handles. + foreach ($com in @($shellItem, $folder, $shell)) { + if ($com) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($com) } + } } } @@ -1964,9 +2502,22 @@ function genpass { finally { $rng.Dispose() } } $password = $result.ToString() - Set-Clipboard $password - Write-Host "Password copied to clipboard." -ForegroundColor Green - return $password + # Clipboard is the primary, scrollback-safe delivery channel. If it is unavailable (headless host, no + # clipboard service, RDP without redirection), Set-Clipboard throws - fall back to a one-time Write-Host so + # the user still gets the password they asked for. Write-Host goes to the host, not the success stream, so + # `$x = genpass` still doesn't capture it. + try { + Set-Clipboard $password -ErrorAction Stop + Write-Host "Password copied to clipboard." -ForegroundColor Green + } + catch { + Write-Warning "Clipboard unavailable ($($_.Exception.Message)); showing the password once below:" + Write-Host $password -ForegroundColor Yellow + } + # Do not return the plaintext: at top-level, PowerShell would print it to the + # terminal scrollback (and to any capturing pipeline/redirect), defeating the + # clipboard-only contract. + return } # Base64 encode/decode @@ -1984,7 +2535,11 @@ function b64d { # VirusTotal file scanner (PS5-compatible, no dependencies) function vtscan { - param([Parameter(Mandatory)][string]$FilePath) + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [Parameter(Mandatory)][string]$FilePath, + [switch]$Upload + ) $apiKey = if ($env:VTCLI_APIKEY) { $env:VTCLI_APIKEY } elseif ($env:VT_API_KEY) { $env:VT_API_KEY } else { $null } if (-not $apiKey) { Write-Host 'Set $env:VTCLI_APIKEY first (free key at https://www.virustotal.com/gui/my-apikey)' -ForegroundColor Red @@ -1999,7 +2554,7 @@ function vtscan { Write-Error "File too large ($sizeMB MB). VirusTotal free limit is 32 MB." return } - $sha = (Get-FileHash $resolved -Algorithm SHA256).Hash.ToLower() + $sha = (Get-FileHash -LiteralPath $resolved.Path -Algorithm SHA256).Hash.ToLower() $headers = @{ 'x-apikey' = $apiKey } $sizeLabel = if ($file.Length -ge 1MB) { "$sizeMB MB" } else { "$([math]::Round($file.Length / 1KB, 1)) KB" } Write-Host "`nFile: $($file.Name) ($sizeLabel)" -ForegroundColor Cyan @@ -2008,7 +2563,7 @@ function vtscan { # Lookup by hash first $found = $false try { - $report = Invoke-RestMethod -Uri "https://www.virustotal.com/api/v3/files/$sha" -Headers $headers -ErrorAction Stop + $report = Invoke-RestMethod -Uri "https://www.virustotal.com/api/v3/files/$sha" -Headers $headers -ErrorAction Stop -UseBasicParsing -TimeoutSec 30 $found = $true } catch { @@ -2052,11 +2607,19 @@ function vtscan { } # File not known - upload + if (-not $Upload) { + Write-Host 'Hash not found. Not uploading by default.' -ForegroundColor Yellow + Write-Host 'Re-run with: vtscan -Upload (submits the file to VirusTotal)' -ForegroundColor Yellow + return + } + if (-not $PSCmdlet.ShouldProcess($resolved.Path, 'Upload file to VirusTotal')) { + return + } Write-Host 'Hash not found, uploading...' -ForegroundColor Yellow $uploadUrl = 'https://www.virustotal.com/api/v3/files' if ($file.Length -gt 10MB) { try { - $uploadUrl = (Invoke-RestMethod -Uri 'https://www.virustotal.com/api/v3/files/upload_url' -Headers $headers -ErrorAction Stop).data + $uploadUrl = (Invoke-RestMethod -Uri 'https://www.virustotal.com/api/v3/files/upload_url' -Headers $headers -ErrorAction Stop -UseBasicParsing -TimeoutSec 30).data if (-not $uploadUrl) { Write-Error "VirusTotal did not return an upload URL."; return } Write-Host 'Using large-file upload endpoint.' -ForegroundColor DarkGray } @@ -2072,12 +2635,14 @@ function vtscan { $safeName = $file.Name -replace '["\r\n]', '_' $header = "--$boundary`r`nContent-Disposition: form-data; name=`"file`"; filename=`"$safeName`"`r`nContent-Type: application/octet-stream`r`n`r`n" $footer = "`r`n--$boundary--`r`n" - $bodyBytes = $enc.GetBytes($header) + $fileBytes + $enc.GetBytes($footer) + # Cast to [byte[]]: [byte[]] + [byte[]] yields an Object[] whose elements are [Byte], which Invoke-WebRequest + # serializes as space-separated decimal strings instead of raw bytes - silently corrupting the binary upload. + $bodyBytes = [byte[]]($enc.GetBytes($header) + $fileBytes + $enc.GetBytes($footer)) try { $resp = Invoke-WebRequest -Uri $uploadUrl ` -Method Post -Headers $headers ` -ContentType "multipart/form-data; boundary=$boundary" ` - -Body $bodyBytes -UseBasicParsing -ErrorAction Stop + -Body $bodyBytes -UseBasicParsing -ErrorAction Stop -TimeoutSec 120 $parsed = $resp.Content | ConvertFrom-Json if (-not $parsed -or -not $parsed.data -or -not $parsed.data.links) { Write-Error "Unexpected VirusTotal upload response." @@ -2113,14 +2678,18 @@ if (Get-Command docker -ErrorAction SilentlyContinue) { # dlogs: follow container logs; dex: exec into container function dlogs { param([Parameter(Mandatory)][string]$Container) - docker logs -f $Container + $old = Push-TabTitle "logs: $Container" + try { docker logs -f $Container } + finally { Pop-TabTitle $old } } function dex { param( [Parameter(Mandatory)][string]$Container, [string]$Shell = 'bash' ) - docker exec -it $Container $Shell + $old = Push-TabTitle "docker: $Container" + try { docker exec -it $Container $Shell } + finally { Pop-TabTitle $old } } # dstop: stop all running containers; dprune: system prune function dstop { @@ -2130,6 +2699,200 @@ if (Get-Command docker -ErrorAction SilentlyContinue) { function dprune { docker system prune -f } } +# WSL wrapper + QoL helpers. All defined only when wsl.exe is available. +if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { + # Wrapper: shows "wsl " in tab title during session. Uses & wsl.exe (with .exe + # suffix) so PS resolves to the native binary, not this function (avoids recursion). + function wsl { + $distro = 'default' + for ($i = 0; $i -lt $args.Count; $i++) { + $a = [string]$args[$i] + if ($a -eq '-d' -or $a -eq '--distribution') { + if ($i + 1 -lt $args.Count) { $distro = [string]$args[$i + 1] } + break + } + } + $oldTitle = Push-TabTitle "wsl: $distro" + try { + if ($MyInvocation.ExpectingInput) { $input | & wsl.exe @args } + else { & wsl.exe @args } + } + finally { Pop-TabTitle $oldTitle } + } + + # List installed WSL distros with state + version. Parses wsl.exe -l -v output which is + # UTF-16 with a null-byte-interspersed encoding that PowerShell decodes inconsistently. + function Get-WslDistro { + [CmdletBinding()] + param() + $raw = & wsl.exe -l -v 2>&1 + $results = @() + foreach ($line in $raw) { + $clean = ([string]$line) -replace "`0", '' + if ($clean -match '^\s*(\*?)\s*([A-Za-z0-9._-]+)\s+(Running|Stopped|Installing|Uninstalling|Converting)\s+(\d+)') { + $results += [PSCustomObject]@{ + Default = ($matches[1] -eq '*') + Name = $matches[2] + State = $matches[3] + Version = [int]$matches[4] + } + } + } + $results + } + + # Open a WSL shell in the current Windows directory. Uses wsl.exe --cd which auto-translates + # Windows path via wslpath. Tab title set by the ssh wrapper pattern via the main 'wsl' fn. + function Enter-WslHere { + [CmdletBinding()] + param([string]$Distro) + $wslArgs = @('--cd', (Get-Location).ProviderPath) + if ($Distro) { $wslArgs = @('-d', $Distro) + $wslArgs } + wsl @wslArgs + } + Set-Alias -Name wsl-here -Value Enter-WslHere + + # Translate Windows path to WSL path via the in-distro 'wslpath -a' utility. + # Two things worth knowing: + # 1. wsl.exe drops single backslashes during argument handoff from Windows -> Linux + # ('C:\foo' arrives as 'C:foo' inside wslpath). We pre-normalize '\' -> '/' since + # wslpath accepts either form for Windows paths. + # 2. The '--' end-of-options marker stops wslpath from treating paths that start with '-' + # (legal POSIX filename, e.g. '-foo') as flag arguments. + function ConvertTo-WslPath { + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)][string]$Path, + [string]$Distro + ) + $normalized = $Path -replace '\\', '/' + $wslArgs = @() + if ($Distro) { $wslArgs += '-d', $Distro } + $wslArgs += 'wslpath', '-a', '--', $normalized + $result = (& wsl.exe @wslArgs 2>&1) + if ($LASTEXITCODE -ne 0) { Write-Error ($result -join ' '); return } + ($result | Select-Object -First 1).ToString().Trim() + } + + # Translate WSL path to Windows path via 'wslpath -w'. Same protections as ConvertTo-WslPath. + # Normalize '\' -> '/' to survive wsl.exe's backslash-dropping arg handoff. + function ConvertTo-WindowsPath { + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)][string]$Path, + [string]$Distro + ) + $normalized = $Path -replace '\\', '/' + $wslArgs = @() + if ($Distro) { $wslArgs += '-d', $Distro } + $wslArgs += 'wslpath', '-w', '--', $normalized + $result = (& wsl.exe @wslArgs 2>&1) + if ($LASTEXITCODE -ne 0) { Write-Error ($result -join ' '); return } + ($result | Select-Object -First 1).ToString().Trim() + } + + # Shutdown all WSL distros, or terminate a specific one. Useful when a distro hangs or + # Docker Desktop / VPN adapters misbehave and need a clean restart. + function Stop-Wsl { + [CmdletBinding()] + param([string]$Distro) + if ($Distro) { + & wsl.exe --terminate $Distro + Write-Host "Terminated: $Distro" -ForegroundColor Green + } + else { + & wsl.exe --shutdown + Write-Host "All WSL distros stopped." -ForegroundColor Green + } + } + + # Get IPv4 address of a WSL distro. Useful for connecting from Windows to a service + # running inside WSL (http server, db, etc.). Returns first IP if multiple. + function Get-WslIp { + [CmdletBinding()] + param([string]$Distro) + $wslArgs = @() + if ($Distro) { $wslArgs += '-d', $Distro } + $wslArgs += 'hostname', '-I' + $out = (& wsl.exe @wslArgs 2>$null | Out-String).Trim() + if (-not $out) { Write-Warning 'No IP returned; is the distro running?'; return } + ($out -split '\s+')[0] + } + + # List files inside a WSL distro via the \\wsl$\\... UNC path. Returns FileInfo + # objects (pipe-friendly): `Get-WslFile Debian /home | Where Name -like 'lenti*'`. + # The distro must be running for the UNC path to be accessible. + function Get-WslFile { + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)][string]$Distro, + [Parameter(Position = 1)][string]$Path = '/', + [switch]$Recurse, + [switch]$Force + ) + $uncRoot = '\\wsl$\' + $Distro + $rel = ($Path -replace '^/', '') -replace '/', '\' + $unc = if ($rel) { Join-Path $uncRoot $rel } else { $uncRoot } + if (-not (Test-Path -LiteralPath $unc)) { + Write-Error "Not accessible: $unc. Distro may be stopped (try: wsl -d $Distro echo ready) or path is wrong." + return + } + Get-ChildItem -LiteralPath $unc -Recurse:$Recurse -Force:$Force + } + + # Internal: resolve WSL UNC path + check reachable. Returns $null with error on failure. + function Resolve-WslUncPath { + param([string]$Distro, [string]$Path = '/') + $uncRoot = '\\wsl$\' + $Distro + $rel = ($Path -replace '^/', '') -replace '/', '\' + $unc = if ($rel) { Join-Path $uncRoot $rel } else { $uncRoot } + if (-not (Test-Path -LiteralPath $unc)) { + Write-Error "Not accessible: $unc. Distro may be stopped (try: wsl -d $Distro echo ready) or path is wrong." + return $null + } + return $unc + } + + # Tree-view of a WSL directory. Uses eza when available (nice icons + colors), falls back + # to Get-ChildItem -Recurse -Depth. Default depth=2 to avoid flooding on '/' ; bump with -Depth. + function Show-WslTree { + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)][string]$Distro, + [Parameter(Position = 1)][string]$Path = '/', + [ValidateRange(1, 10)][int]$Depth = 2, + [switch]$All + ) + $unc = Resolve-WslUncPath -Distro $Distro -Path $Path + if (-not $unc) { return } + if (Get-Command eza -ErrorAction SilentlyContinue) { + $ezaArgs = @('--tree', "--level=$Depth", '--icons', '--git-ignore') + if ($All) { $ezaArgs += '-a' } + & eza @ezaArgs $unc + } + else { + # Fallback: PS-native tree. -Depth counts levels INTO the directory. + Get-ChildItem -LiteralPath $unc -Recurse -Depth ($Depth - 1) -Force:$All | + Select-Object Mode, Length, LastWriteTime, FullName + } + } + Set-Alias -Name wsl-tree -Value Show-WslTree + + # Open Windows Explorer at a WSL distro path for native GUI browsing. Quickest way to + # scroll through a whole distro filesystem visually, thumbnail previews for images, etc. + function Open-WslExplorer { + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)][string]$Distro, + [Parameter(Position = 1)][string]$Path = '/' + ) + $unc = Resolve-WslUncPath -Distro $Distro -Path $Path + if (-not $unc) { return } + explorer.exe $unc + } + Set-Alias -Name wsl-explorer -Value Open-WslExplorer +} + # System Admin function svc { param( @@ -2171,59 +2934,317 @@ function svc { # Reload profile in current session (useful after editing profile_user.ps1 or user-settings.json) function reload { . $PROFILE } -# Clear profile cache (Oh My Posh and our own) to resolve issues with stale data or after manual edits to cache files. Terminal restart is required to see changes. -function Clear-ProfileCache { - $cacheDir = Join-Path $env:LOCALAPPDATA "PowerShellProfile" - if (-not (Test-Path $cacheDir)) { - Clear-OhMyPoshCaches -Quiet - Write-Host "No cache directory found." -ForegroundColor Yellow - return - } - $items = Get-ChildItem $cacheDir -Exclude "user-settings.json" -ErrorAction SilentlyContinue - if (-not $items) { - Clear-OhMyPoshCaches -Quiet - Write-Host "Cache is already clean." -ForegroundColor Green - return - } - foreach ($item in $items) { - Remove-Item $item.FullName -Force -ErrorAction SilentlyContinue - Write-Host " Removed $($item.Name)" -ForegroundColor DarkGray - } - Clear-OhMyPoshCaches -Quiet - Restart-TerminalToApply -Message "Profile cache cleared. Restarting terminal..." -} - -# Uninstall profile components with granular options. By default, only non-user data caches and PSFzf module are removed to allow for quick resets without data loss. -# Use -All to remove everything including user settings and fonts. Windows Terminal settings are handled in a way to allow easy restoration of previous state if not doing a hard reset. -function Uninstall-Profile { - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] - param( - [switch]$RemoveTools, - [switch]$RemoveUserData, - [switch]$RemoveFonts, - [switch]$All, - [switch]$HardResetWindowsTerminal - ) +# Diagnose a possibly-broken install: walks through tools, caches, fonts, PATH, modules, +# and plugins, reporting OK / WARN / FAIL per check. Users hitting weird prompts or missing +# predictions run this before filing an issue. +function Test-ProfileHealth { + [CmdletBinding()] + param() - if ($All) { $RemoveTools = $true; $RemoveUserData = $true; $RemoveFonts = $true } - $preserved = @() + $results = @() - # Phase 1: Windows Terminal settings - $wtSettingsPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' - if (Test-Path (Split-Path $wtSettingsPath)) { - $wtLocalState = Split-Path $wtSettingsPath - $backups = Get-ChildItem -Path $wtLocalState -Filter 'settings.json.*.bak' -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending + # Managed tools + foreach ($tool in $script:ProfileTools) { + $path = Get-ProfileToolExecutablePath -Tool $tool + if ($path) { + $results += [pscustomobject]@{ Category = 'Tools'; Check = $tool.Name; Status = 'OK'; Detail = $path } + } + else { + $results += [pscustomobject]@{ Category = 'Tools'; Check = $tool.Name; Status = 'FAIL'; Detail = 'not installed (Update-Profile or setup.ps1)' } + } + } - if ($HardResetWindowsTerminal) { - if (Test-Path $wtSettingsPath) { - if ($PSCmdlet.ShouldProcess($wtSettingsPath, 'Delete WT settings for hard reset')) { - Remove-Item $wtSettingsPath -Force -ErrorAction SilentlyContinue - Write-Host ' Deleted Windows Terminal settings.json (WT will recreate defaults on next launch).' -ForegroundColor Green - } + # Disk caches + foreach ($c in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json')) { + $p = Join-Path $cacheDir $c + if (-not (Test-Path $p)) { + $results += [pscustomobject]@{ Category = 'Caches'; Check = $c; Status = 'WARN'; Detail = 'missing (will regenerate on next load)' } + continue + } + $size = (Get-Item $p).Length + if ($size -eq 0) { + $results += [pscustomobject]@{ Category = 'Caches'; Check = $c; Status = 'FAIL'; Detail = 'empty file (corrupt)' } + continue + } + if ($c -like '*.json') { + try { + $null = Get-Content $p -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $results += [pscustomobject]@{ Category = 'Caches'; Check = $c; Status = 'OK'; Detail = "$size bytes, parses" } } - if ($backups) { - foreach ($bak in $backups) { + catch { + $results += [pscustomobject]@{ Category = 'Caches'; Check = $c; Status = 'FAIL'; Detail = "JSON parse error: $($_.Exception.Message)" } + } + } + else { + $results += [pscustomobject]@{ Category = 'Caches'; Check = $c; Status = 'OK'; Detail = "$size bytes" } + } + } + + # User-settings.json (overrides) + $us = Join-Path $cacheDir 'user-settings.json' + if (Test-Path $us) { + try { + $null = Get-Content $us -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $results += [pscustomobject]@{ Category = 'Config'; Check = 'user-settings.json'; Status = 'OK'; Detail = 'parses' } + } + catch { + $results += [pscustomobject]@{ Category = 'Config'; Check = 'user-settings.json'; Status = 'FAIL'; Detail = $_.Exception.Message } + } + } + else { + $results += [pscustomobject]@{ Category = 'Config'; Check = 'user-settings.json'; Status = 'WARN'; Detail = 'missing (no overrides applied)' } + } + + # Font from terminal-config.json.fontInstall.displayName + $tcPath = Join-Path $cacheDir 'terminal-config.json' + if (Test-Path $tcPath) { + try { + $tc = Get-Content $tcPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $fontName = if ($tc.fontInstall) { $tc.fontInstall.displayName } else { $null } + if ($fontName) { + # System.Drawing ships on Windows PowerShell 5.1 and is available via the + # System.Drawing.Common package on PS 7 Windows. On Linux/Mac PS the type + # may not load; treat that as WARN (can't verify) rather than FAIL (missing). + if (-not ('System.Drawing.Text.InstalledFontCollection' -as [type])) { + Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue + } + if (-not ('System.Drawing.Text.InstalledFontCollection' -as [type])) { + $results += [pscustomobject]@{ Category = 'Fonts'; Check = $fontName; Status = 'WARN'; Detail = 'System.Drawing unavailable on this host; cannot verify' } + } + else { + $installed = $false + try { + $fc = New-Object System.Drawing.Text.InstalledFontCollection + $installed = $fc.Families.Name -contains $fontName + $fc.Dispose() + } + catch { $null = $_ } + if ($installed) { + $results += [pscustomobject]@{ Category = 'Fonts'; Check = $fontName; Status = 'OK'; Detail = 'installed' } + } + else { + $results += [pscustomobject]@{ Category = 'Fonts'; Check = $fontName; Status = 'FAIL'; Detail = 'not installed (run setup.ps1 -Wizard or Update-Profile)' } + } + } + } + } + catch { $null = $_ } + } + + # PATH - WindowsApps (winget shims location) + $wapps = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' + if (($env:PATH -split ';') -contains $wapps) { + $results += [pscustomobject]@{ Category = 'PATH'; Check = 'WindowsApps'; Status = 'OK'; Detail = 'in PATH' } + } + else { + $results += [pscustomobject]@{ Category = 'PATH'; Check = 'WindowsApps'; Status = 'WARN'; Detail = 'missing (winget shims may not resolve)' } + } + + # Modules + if (Get-Module -ListAvailable -Name PSFzf) { + $results += [pscustomobject]@{ Category = 'Modules'; Check = 'PSFzf'; Status = 'OK'; Detail = 'available' } + } + else { + $results += [pscustomobject]@{ Category = 'Modules'; Check = 'PSFzf'; Status = 'WARN'; Detail = 'not installed (Ctrl+R/Ctrl+T disabled)' } + } + $prl = Get-Module PSReadLine + if ($prl) { + $results += [pscustomobject]@{ Category = 'Modules'; Check = 'PSReadLine'; Status = 'OK'; Detail = "v$($prl.Version)" } + } + else { + $results += [pscustomobject]@{ Category = 'Modules'; Check = 'PSReadLine'; Status = 'FAIL'; Detail = 'not loaded' } + } + + # Plugins + $pluginDir = Join-Path $cacheDir 'plugins' + if (Test-Path $pluginDir) { + $plugins = @(Get-ChildItem $pluginDir -Filter *.ps1 -ErrorAction SilentlyContinue) + $results += [pscustomobject]@{ Category = 'Plugins'; Check = 'user plugins'; Status = 'OK'; Detail = "$($plugins.Count) file(s) in $pluginDir" } + } + + # Extensibility state + $trustedCount = if ($script:PSP.TrustedDirs) { $script:PSP.TrustedDirs.Count } else { 0 } + $results += [pscustomobject]@{ Category = 'Extension'; Check = 'trusted directories'; Status = 'OK'; Detail = "$trustedCount entries" } + + # Format + render + $okCount = @($results | Where-Object Status -eq 'OK').Count + $warnCount = @($results | Where-Object Status -eq 'WARN').Count + $failCount = @($results | Where-Object Status -eq 'FAIL').Count + + Write-Host '' + Write-Host 'Profile Health Check' -ForegroundColor Cyan + Write-Host '====================' + $fmt = '{0,-10} {1,-30} {2,-5} {3}' + Write-Host ($fmt -f 'Category', 'Check', 'Stat', 'Detail') -ForegroundColor DarkGray + foreach ($r in $results) { + $color = switch ($r.Status) { 'OK' { 'Green' } 'WARN' { 'Yellow' } 'FAIL' { 'Red' } default { 'White' } } + $detail = if ($r.Detail.Length -gt 80) { $r.Detail.Substring(0, 77) + '...' } else { $r.Detail } + Write-Host ($fmt -f $r.Category, $r.Check, $r.Status, $detail) -ForegroundColor $color + } + Write-Host '' + $summaryColor = if ($failCount -gt 0) { 'Red' } elseif ($warnCount -gt 0) { 'Yellow' } else { 'Green' } + Write-Host ("Summary: {0} OK, {1} WARN, {2} FAIL" -f $okCount, $warnCount, $failCount) -ForegroundColor $summaryColor + + # Return the objects too so scripts can query: Test-ProfileHealth | Where Status -eq 'FAIL' + $results +} +Set-Alias -Name psp-doctor -Value Test-ProfileHealth -Scope Script + +# Clear profile cache (Oh My Posh and our own) to resolve issues with stale data or after manual edits to cache files. Terminal restart is required to see changes. +function Clear-ProfileCache { + $cacheDir = Join-Path $env:LOCALAPPDATA "PowerShellProfile" + if (-not (Test-Path $cacheDir)) { + Clear-OhMyPoshCaches -Quiet + Write-Host "No cache directory found." -ForegroundColor Yellow + return + } + # Preserve user-owned content: user-settings.json (config) and plugins/ (user-installed scripts). + # Everything else is regenerable cache and can be safely removed. + $preservedNames = @('user-settings.json', 'plugins') + $items = Get-ChildItem $cacheDir -ErrorAction SilentlyContinue | Where-Object { $preservedNames -notcontains $_.Name } + if (-not $items) { + Clear-OhMyPoshCaches -Quiet + Write-Host "Cache is already clean." -ForegroundColor Green + return + } + foreach ($item in $items) { + if ($item.PSIsContainer) { + Remove-Item $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + else { + Remove-Item $item.FullName -Force -ErrorAction SilentlyContinue + } + Write-Host " Removed $($item.Name)" -ForegroundColor DarkGray + } + Clear-OhMyPoshCaches -Quiet + Restart-TerminalToApply -Message "Profile cache cleared. Restarting terminal..." +} + +# Re-run setup.ps1 -Wizard so the user can pick a new OMP theme, WT scheme, font, and +# features without reinstalling from scratch. Downloads a fresh setup.ps1 to %TEMP% +# (to pick up the latest wizard logic) and relaunches elevated in a new pwsh window. +# +# Security: downloads remote code, so the user must either pin setup.ps1 with +# -ExpectedSha256, then pin the install bundle with -BundleExpectedSha256, or +# explicitly use -SkipHashCheck. Exit code of the child process is captured so +# we do not claim "Wizard complete" on a failure. +function Invoke-ProfileWizard { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [switch]$Resume, + [switch]$NoElevate, + [ValidatePattern('^[A-Fa-f0-9]{64}$')] + [string]$ExpectedSha256, + [ValidatePattern('^[A-Fa-f0-9]{64}$')] + [string]$BundleExpectedSha256, + [switch]$SkipHashCheck + ) + + if ($BundleExpectedSha256 -and $SkipHashCheck) { + Write-Error 'Use either -BundleExpectedSha256 or -SkipHashCheck, not both.' + return + } + + $setupUrl = "$repo_root/$repo_name/main/setup.ps1" + $setupLocal = Join-Path ([System.IO.Path]::GetTempPath()) ("psp-reconfigure-{0}.ps1" -f ([System.IO.Path]::GetRandomFileName())) + + Write-Host "Downloading latest setup.ps1 from $setupUrl" -ForegroundColor Cyan + if (-not $PSCmdlet.ShouldProcess($setupUrl, 'Download and execute remote setup.ps1')) { return } + try { Invoke-DownloadWithRetry -Uri $setupUrl -OutFile $setupLocal } + catch { + Write-Warning "Download failed: $($_.Exception.Message)" + return + } + + try { + $actualHash = (Get-FileHash -LiteralPath $setupLocal -Algorithm SHA256).Hash + if ($ExpectedSha256) { + if ($actualHash -ine $ExpectedSha256.Trim()) { + Write-Error "SHA256 mismatch. Expected: $ExpectedSha256. Actual: $actualHash. Aborting." + return + } + Write-Host " Hash verified: $actualHash" -ForegroundColor Green + } + elseif (-not $SkipHashCheck) { + Write-Host " Downloaded setup.ps1 SHA256: $actualHash" -ForegroundColor Yellow + Write-Host " (Hash is computed over the download just made; it confirms integrity," -ForegroundColor DarkYellow + Write-Host " not upstream authenticity. Verify the commit out-of-band before pinning.)" -ForegroundColor DarkYellow + Write-Host " Pin it: Invoke-ProfileWizard -ExpectedSha256 '$actualHash'" -ForegroundColor Yellow + Write-Host " Then pin the setup bundle with -BundleExpectedSha256 when prompted." -ForegroundColor Yellow + Write-Host " Or skip: Invoke-ProfileWizard -SkipHashCheck" -ForegroundColor Yellow + throw "Hash input required. Re-run with -ExpectedSha256 or -SkipHashCheck." + } + + $shellArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $setupLocal, '-Wizard') + if ($Resume) { $shellArgs += '-Resume' } + if ($SkipHashCheck) { + $shellArgs += '-SkipHashCheck' + } + elseif ($BundleExpectedSha256) { + $shellArgs += @('-ExpectedSha256', $BundleExpectedSha256) + } + else { + Write-Host " setup.ps1 will stop before making changes and print the install-bundle hash." -ForegroundColor Yellow + Write-Host " Re-run with -BundleExpectedSha256 '' or -SkipHashCheck to apply the wizard." -ForegroundColor Yellow + } + + $pwshExe = if ((Get-Command pwsh -ErrorAction SilentlyContinue)) { 'pwsh' } else { 'powershell' } + $exitCode = 1 + + if ($isAdmin -or $NoElevate) { + & $pwshExe @shellArgs + $exitCode = $LASTEXITCODE + } + else { + Write-Host "Launching elevated wizard in a new window ..." -ForegroundColor Cyan + $proc = Start-Process -FilePath $pwshExe -ArgumentList (ConvertTo-NativeArgumentLine $shellArgs) -Verb RunAs -Wait -PassThru + $exitCode = if ($proc) { $proc.ExitCode } else { 1 } + } + + if ($exitCode -eq 0) { + Write-Host "Wizard complete. Reload your shell (run 'reload') to apply changes." -ForegroundColor Green + } + else { + Write-Warning "Wizard exited with code $exitCode. Review the output above." + } + } + finally { + Remove-Item $setupLocal -Force -ErrorAction SilentlyContinue + } +} +Set-Alias -Name Reconfigure-Profile -Value Invoke-ProfileWizard -Scope Script + +# Uninstall profile components with granular options. By default, only non-user data caches and PSFzf module are removed to allow for quick resets without data loss. +# Use -All to remove everything including user settings and fonts. Windows Terminal settings are handled in a way to allow easy restoration of previous state if not doing a hard reset. +function Uninstall-Profile { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [switch]$RemoveTools, + [switch]$RemoveUserData, + [switch]$RemoveFonts, + [switch]$All, + [switch]$HardResetWindowsTerminal + ) + + if ($All) { $RemoveTools = $true; $RemoveUserData = $true; $RemoveFonts = $true } + $preserved = @() + + # Phase 1: Windows Terminal settings + $wtSettingsPath = Get-WindowsTerminalSettingsPath + if ($wtSettingsPath -and (Test-Path (Split-Path $wtSettingsPath))) { + $wtLocalState = Split-Path $wtSettingsPath + $backups = Get-ChildItem -Path $wtLocalState -Filter 'settings.json.*.bak' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending + + if ($HardResetWindowsTerminal) { + if (Test-Path $wtSettingsPath) { + if ($PSCmdlet.ShouldProcess($wtSettingsPath, 'Delete WT settings for hard reset')) { + Remove-Item $wtSettingsPath -Force -ErrorAction SilentlyContinue + Write-Host ' Deleted Windows Terminal settings.json (WT will recreate defaults on next launch).' -ForegroundColor Green + } + } + if ($backups) { + foreach ($bak in $backups) { if ($PSCmdlet.ShouldProcess($bak.FullName, 'Remove WT backup')) { Remove-Item $bak.FullName -Force -ErrorAction SilentlyContinue } @@ -2249,16 +3270,23 @@ function Uninstall-Profile { $cacheDir = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' if (Test-Path $cacheDir) { $excludes = @() - if (-not $RemoveUserData) { $excludes += 'user-settings.json'; $excludes += 'profile_user.ps1' } + # plugins/ holds user-authored, auto-loaded scripts (Clear-ProfileCache preserves them too), so keep it + # unless -RemoveUserData. ('profile_user.ps1' lives in Split-Path $PROFILE, never here - its old exclude + # entry was a no-op; the real preservation happens in Phase 7.) + if (-not $RemoveUserData) { $excludes += 'user-settings.json'; $excludes += 'plugins' } $cacheItems = Get-ChildItem $cacheDir -ErrorAction SilentlyContinue | Where-Object { $excludes -notcontains $_.Name } foreach ($item in $cacheItems) { - if ($PSCmdlet.ShouldProcess($item.FullName, 'Remove cache file')) { + $itemLabel = if ($item.PSIsContainer) { 'Remove cache directory' } else { 'Remove cache file' } + if ($PSCmdlet.ShouldProcess($item.FullName, $itemLabel)) { Remove-Item $item.FullName -Force -Recurse -ErrorAction SilentlyContinue Write-Host " Removed $($item.Name)" -ForegroundColor DarkGray } } - if (-not $RemoveUserData) { $preserved += 'user-settings.json (use -RemoveUserData to remove)' } + if (-not $RemoveUserData) { + $preserved += 'user-settings.json (use -RemoveUserData to remove)' + if (Test-Path (Join-Path $cacheDir 'plugins')) { $preserved += 'plugins/ (use -RemoveUserData to remove)' } + } # Remove empty cache dir $remaining = Get-ChildItem $cacheDir -ErrorAction SilentlyContinue if (-not $remaining) { @@ -2419,6 +3447,9 @@ function Uninstall-Profile { Write-Warning ' Font removal requires an elevated (admin) terminal. Skipping.' } else { + # Derive the font filter from terminal-config.json's fontInstall.displayName so uninstall + # mirrors whatever font the current install actually placed. Falls back to CaskaydiaCove + # only if terminal-config.json is unreadable (install default). $fontDisplayName = 'CaskaydiaCove NF' try { $tcPath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\terminal-config.json' @@ -2428,39 +3459,51 @@ function Uninstall-Profile { } } catch { $null = $_ } + $tokens = $fontDisplayName -split '\s+' | Where-Object { $_ } + # Avoid -Filter globbing (which treats [ ] * ? as wildcards - can match unintended + # files if displayName contains those chars). Use -match with an anchored regex on .ttf. + $regexPattern = ($tokens | ForEach-Object { [regex]::Escape($_) }) -join '.*' $fontDir = Join-Path $env:SystemRoot 'Fonts' $regPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - $fontFiles = Get-ChildItem $fontDir -Filter '*CaskaydiaCove*NF*.ttf' -ErrorAction SilentlyContinue + $fontFiles = Get-ChildItem $fontDir -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -eq '.ttf' -and $_.Name -match $regexPattern } if ($fontFiles) { foreach ($f in $fontFiles) { if ($PSCmdlet.ShouldProcess($f.Name, 'Remove font file')) { Remove-Item $f.FullName -Force -ErrorAction SilentlyContinue } } - # Clean registry entries $regEntries = Get-ItemProperty $regPath -ErrorAction SilentlyContinue if ($regEntries) { - $regEntries.PSObject.Properties | Where-Object { $_.Name -match 'Caskaydia' -and $_.Name -match 'NF' } | ForEach-Object { + $regEntries.PSObject.Properties | Where-Object { $_.Name -match $regexPattern } | ForEach-Object { if ($PSCmdlet.ShouldProcess($_.Name, 'Remove font registry entry')) { Remove-ItemProperty -Path $regPath -Name $_.Name -Force -ErrorAction SilentlyContinue } } } - Write-Host " Removed $($fontDisplayName) font files." -ForegroundColor Green + Write-Host " Removed $fontDisplayName font files." -ForegroundColor Green } else { - Write-Host " No Nerd Font files found to remove." -ForegroundColor DarkGray + Write-Host " No Nerd Font files matching '$fontDisplayName' found to remove." -ForegroundColor DarkGray } } } else { $preserved += 'Nerd Fonts (use -RemoveFonts to remove, requires admin)' } - # Phase 6: Remove telemetry opt-out env var + # Phase 6: Remove telemetry opt-out env var (only if we set it). + # Ownership is tracked via $cacheDir\telemetry.owned (written by setup.ps1 when the user + # answered yes to the opt-out prompt). Without the marker we leave the env var alone, + # because other tools (or the user's own config) may depend on it. $isElevatedNow = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $telemetryMarker = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\telemetry.owned' if ([System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine')) { - if ($isElevatedNow) { + if (-not (Test-Path -LiteralPath $telemetryMarker)) { + Write-Host ' Leaving POWERSHELL_TELEMETRY_OPTOUT alone (no ownership marker; value may belong to another tool or user setting).' -ForegroundColor DarkGray + } + elseif ($isElevatedNow) { if ($PSCmdlet.ShouldProcess('POWERSHELL_TELEMETRY_OPTOUT', 'Remove machine environment variable')) { [System.Environment]::SetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', $null, [System.EnvironmentVariableTarget]::Machine) + Remove-Item -LiteralPath $telemetryMarker -Force -ErrorAction SilentlyContinue Write-Host ' Removed POWERSHELL_TELEMETRY_OPTOUT env var.' -ForegroundColor Green } } @@ -2468,6 +3511,10 @@ function Uninstall-Profile { Write-Host ' Skipping POWERSHELL_TELEMETRY_OPTOUT removal (requires admin).' -ForegroundColor DarkGray } } + elseif (Test-Path -LiteralPath $telemetryMarker) { + # Env var already gone; clean up the stale marker so repeat uninstalls stay idempotent. + Remove-Item -LiteralPath $telemetryMarker -Force -ErrorAction SilentlyContinue + } # Phase 7: Profile files $docsRoot = Split-Path (Split-Path $PROFILE) @@ -2521,7 +3568,7 @@ function weather { # Try wttr.in first, fall back to Open-Meteo if unreachable $url = if ($encoded) { "https://wttr.in/${encoded}?format=3" } else { "https://wttr.in/?format=3" } try { - $r = Invoke-RestMethod $url -TimeoutSec 10 -Headers @{ 'User-Agent' = 'curl' } + $r = Invoke-RestMethod $url -TimeoutSec 10 -Headers @{ 'User-Agent' = 'curl' } -UseBasicParsing $text = ($r | Out-String).Trim() if ($text -and $text -notmatch '(?i)unknown|error|not found') { $text @@ -2533,12 +3580,12 @@ function weather { # Fallback: Open-Meteo (free, no API key) try { $loc = if ($City) { - $geo = Invoke-RestMethod "https://geocoding-api.open-meteo.com/v1/search?name=$encoded&count=1" -TimeoutSec 5 + $geo = Invoke-RestMethod "https://geocoding-api.open-meteo.com/v1/search?name=$encoded&count=1" -TimeoutSec 5 -UseBasicParsing if (-not $geo -or -not $geo.results -or @($geo.results).Count -eq 0) { Write-Error "City '$City' not found."; return } $geo.results[0] } else { - $ip = Invoke-RestMethod "https://ipinfo.io/json" -TimeoutSec 5 + $ip = Invoke-RestMethod "https://ipinfo.io/json" -TimeoutSec 5 -UseBasicParsing if (-not $ip -or -not $ip.loc -or $ip.loc -notmatch ',') { Write-Error "Could not determine location from IP."; return } $ll = $ip.loc -split ',' if ($ll.Count -lt 2) { Write-Error "Malformed location data from IP lookup."; return } @@ -2547,7 +3594,7 @@ function weather { if (-not $loc) { return } $lat = ([double]$loc.latitude).ToString([System.Globalization.CultureInfo]::InvariantCulture) $lon = ([double]$loc.longitude).ToString([System.Globalization.CultureInfo]::InvariantCulture) - $wx = Invoke-RestMethod "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,weather_code" -TimeoutSec 5 + $wx = Invoke-RestMethod "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon¤t=temperature_2m,weather_code" -TimeoutSec 5 -UseBasicParsing if (-not $wx -or -not $wx.current) { Write-Error "Weather API returned no data."; return } $temp = $wx.current.temperature_2m $unit = if ($wx.current_units -and $wx.current_units.temperature_2m) { $wx.current_units.temperature_2m } else { 'C' } @@ -2597,10 +3644,10 @@ function hosts { $cmdInfo = Get-Command $editor -ErrorAction SilentlyContinue $editorPath = if ($cmdInfo -and $cmdInfo.Source) { $cmdInfo.Source } else { $editor } if ($cmdInfo -and $cmdInfo.CommandType -eq 'Application' -and $editorPath -match '\.(cmd|bat)$') { - Start-Process cmd -Verb RunAs -WindowStyle Hidden -ArgumentList "/c `"$editorPath`" `"$hostsPath`"" + Start-Process -FilePath cmd.exe -Verb RunAs -WindowStyle Hidden -ArgumentList (ConvertTo-NativeArgumentLine (@('/c', $editorPath) + @($script:ResolvedEditorArgs) + @($hostsPath))) } else { - Start-Process $editorPath $hostsPath -Verb RunAs + Start-Process -FilePath $editorPath -ArgumentList (ConvertTo-NativeArgumentLine (@($script:ResolvedEditorArgs) + @($hostsPath))) -Verb RunAs } } @@ -2609,8 +3656,9 @@ function speedtest { Write-Host "Testing download speed..." -ForegroundColor Cyan $url = "https://speed.cloudflare.com/__down?bytes=25000000" $start = Get-Date + $oldTitle = Push-TabTitle 'speedtest' try { - Invoke-RestMethod $url -TimeoutSec 30 -ErrorAction Stop | Out-Null + Invoke-RestMethod $url -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop | Out-Null $elapsed = [math]::Max(((Get-Date) - $start).TotalSeconds, 0.001) $mbps = [math]::Round((25 * 8) / $elapsed, 1) Write-Host "Download: ~${mbps} Mbps ($([math]::Round($elapsed, 1))s for 25 MB)" -ForegroundColor Green @@ -2619,6 +3667,7 @@ function speedtest { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } Write-Error "Speed test failed: $_" } + finally { Pop-TabTitle $oldTitle } } # Get size of a file or directory in a human-readable format. For directories, it sums the sizes of all contained files recursively. Handles errors gracefully and formats output with appropriate units. @@ -2651,6 +3700,40 @@ function eventlog { # SSH & Remote if (Get-Command ssh -ErrorAction SilentlyContinue) { + # Wrapper: inject short ConnectTimeout and keepalive defaults so ssh.exe fails fast + # instead of ignoring Ctrl+C during a hung TCP connect (long-standing Windows OpenSSH quirk). + # User-supplied -o values take precedence because we only inject when the option is absent. + function ssh { + # Match both OpenSSH forms: '-o Key=Val' (space) and '-oKey=Val' (no space). + # \s* = zero or more whitespace. Otherwise users passing '-oConnectTimeout=60' would + # get our default injected ahead, and OpenSSH takes first-wins so their override loses. + $argText = ($args -join ' ') + $extra = @() + if ($argText -notmatch '-o\s*ConnectTimeout') { $extra += '-o'; $extra += 'ConnectTimeout=10' } + if ($argText -notmatch '-o\s*ServerAliveInterval') { $extra += '-o'; $extra += 'ServerAliveInterval=30' } + if ($argText -notmatch '-o\s*ServerAliveCountMax') { $extra += '-o'; $extra += 'ServerAliveCountMax=3' } + # Tab title: first non-flag, non-option-value arg is typically the target (user@host). + # Skips flags (-X) and values of options that take one (-p 22, -i keyfile, etc.). + $target = $null + $skipNext = $false + $flagsWithValue = @('-p', '-i', '-l', '-o', '-F', '-L', '-R', '-D', '-W', '-B', '-b', '-c', '-E', '-e', '-I', '-J', '-m', '-O', '-Q', '-S', '-w') + foreach ($a in $args) { + if ($skipNext) { $skipNext = $false; continue } + if ($a -is [string] -and $a.StartsWith('-')) { + if ($flagsWithValue -contains $a) { $skipNext = $true } + continue + } + $target = [string]$a + break + } + $oldTitle = if ($target) { Push-TabTitle "ssh $target" } else { $null } + try { + if ($MyInvocation.ExpectingInput) { $input | & ssh.exe @extra @args } + else { & ssh.exe @extra @args } + } + finally { Pop-TabTitle $oldTitle } + } + # Copy SSH public key to remote host (ssh-copy-id equivalent) function Copy-SshKey { param([Parameter(Mandatory)][string]$RemoteHost) @@ -2709,6 +3792,257 @@ function killport { else { Write-Warning "No processes were stopped on port $Port." } } +# Interactive port killer. Lists every listening TCP port with PID + process name, +# opens fzf (multi-select with Tab) or Out-GridView as fallback, kills everything picked. +# Perfect for "I started 5 dev servers today, which ones are still running?". +function Stop-ListeningPort { + [CmdletBinding(SupportsShouldProcess = $true)] + param() + $conns = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Sort-Object LocalPort) + if (-not $conns) { Write-Host "No listening TCP ports." -ForegroundColor Yellow; return } + $rows = foreach ($c in $conns) { + $proc = Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue + [PSCustomObject]@{ + Port = $c.LocalPort + Address = $c.LocalAddress + PID = $c.OwningProcess + Process = if ($proc) { $proc.ProcessName } else { '-' } + } + } + $selected = @() + if (Get-Command fzf -ErrorAction SilentlyContinue) { + # Format each row into a single fzf line; parse selection back to PID via last column. + $lines = $rows | ForEach-Object { + ('{0,-7} {1,-20} {2,-8} {3}' -f $_.Port, $_.Address, $_.PID, $_.Process) + } + $picked = $lines | fzf --multi --header='Tab to multi-select, Enter to kill' --prompt='killport> ' + if (-not $picked) { Write-Host 'Nothing selected.' -ForegroundColor DarkGray; return } + foreach ($line in @($picked)) { + # PID is the third whitespace-separated column in our formatted line. + $cols = $line -split '\s+' | Where-Object { $_ } + if ($cols.Count -ge 3) { + $selected += ($rows | Where-Object { [string]$_.PID -eq $cols[2] } | Select-Object -First 1) + } + } + } + else { + $selected = @($rows | Out-GridView -Title 'Select ports to kill (Ctrl/Shift for multi)' -PassThru) + if (-not $selected) { Write-Host 'Nothing selected.' -ForegroundColor DarkGray; return } + } + foreach ($row in $selected) { + if ($row.PID -eq 0 -or $row.PID -eq 4) { + Write-Warning "Skipping system PID $($row.PID) on port $($row.Port)." + continue + } + if ($PSCmdlet.ShouldProcess("PID $($row.PID) ($($row.Process)) on port $($row.Port)", 'Stop process')) { + try { + Stop-Process -Id $row.PID -Force -ErrorAction Stop + Write-Host ("Killed: {0} (PID {1}) on port {2}" -f $row.Process, $row.PID, $row.Port) -ForegroundColor Green + } + catch { Write-Warning ("Could not stop PID {0}: {1}" -f $row.PID, $_.Exception.Message) } + } + } +} +Set-Alias -Name killports -Value Stop-ListeningPort + +# ========================================================================== +# Stuck processes / locked files +# ========================================================================== + +# Internal: compile the Restart-Manager P/Invoke wrapper once per session. +# Uses the same Windows API Explorer uses when it says "The action can't be completed +# because the file is open in X". Reliable across NTFS, mapped drives, network shares. +function Initialize-RestartManagerType { + if ('PSP.RestartManager' -as [type]) { return } + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +namespace PSP { + public static class RestartManager { + [StructLayout(LayoutKind.Sequential)] + struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; } + const int CCH_RM_MAX_APP_NAME = 255; + const int CCH_RM_MAX_SVC_NAME = 63; + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_APP_NAME + 1)] public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CCH_RM_MAX_SVC_NAME + 1)] public string strServiceShortName; + public int ApplicationType; public uint AppStatus; public uint TSSessionId; + [MarshalAs(UnmanagedType.Bool)] public bool bRestartable; + } + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFilenames, uint nApplications, [In] RM_UNIQUE_PROCESS[] rgApplications, uint nServices, string[] rgsServiceNames); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Auto)] + static extern int RmStartSession(out uint pSessionHandle, int dwSessionFlags, string strSessionKey); + [DllImport("rstrtmgr.dll")] static extern int RmEndSession(uint pSessionHandle); + [DllImport("rstrtmgr.dll")] + static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + public static List WhoIsLocking(string path) { + uint handle; string key = Guid.NewGuid().ToString(); + List ids = new List(); + int r = RmStartSession(out handle, 0, key); + if (r != 0) throw new Exception("RmStartSession failed: " + r); + try { + r = RmRegisterResources(handle, 1, new string[] { path }, 0, null, 0, null); + if (r != 0) throw new Exception("RmRegisterResources failed: " + r); + uint needed = 0; uint count = 0; uint reason = 0; + r = RmGetList(handle, out needed, ref count, new RM_PROCESS_INFO[0], ref reason); + if (r == 234) { + RM_PROCESS_INFO[] info = new RM_PROCESS_INFO[needed]; count = needed; + r = RmGetList(handle, out needed, ref count, info, ref reason); + if (r != 0) throw new Exception("RmGetList failed: " + r); + for (int i = 0; i < count; i++) ids.Add(info[i].Process.dwProcessId); + } + else if (r != 0) throw new Exception("RmGetList failed: " + r); + } + finally { RmEndSession(handle); } + return ids; + } + } +} +'@ -ErrorAction Stop +} + +# Find which processes are holding a file or directory open. Works for any reason a file +# is locked: antivirus scanning, Explorer preview, IDE index, shared mapping, etc. +function Find-FileLocker { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Path not found: $Path"; return } + try { Initialize-RestartManagerType } + catch { Write-Error "Could not load Restart-Manager API: $($_.Exception.Message)"; return } + $ids = [PSP.RestartManager]::WhoIsLocking($resolved.ProviderPath) + if (-not $ids -or $ids.Count -eq 0) { + Write-Host "No process is holding a lock on: $($resolved.ProviderPath)" -ForegroundColor Green + return + } + foreach ($procId in $ids) { + $p = Get-Process -Id $procId -ErrorAction SilentlyContinue + if ($p) { + [PSCustomObject]@{ + PID = $procId + Name = $p.ProcessName + WindowTitle = $p.MainWindowTitle + Started = $p.StartTime + Path = try { $p.Path } catch { '' } + } + } + else { + [PSCustomObject]@{ + PID = $procId; Name = ''; WindowTitle = ''; Started = $null; Path = $null + } + } + } +} + +# Aggressively kill a process that won't die with ordinary Stop-Process. Escalates: +# Stop-Process -Force -> taskkill /F -> taskkill /F /T (child tree). +# Accepts process name (all instances), PID (single), or pipeline of either. +function Stop-StuckProcess { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')] + param( + [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByName', ValueFromPipeline)] + [string[]]$Name, + [Parameter(Mandatory, ParameterSetName = 'ById')][int[]]$Id, + [switch]$Tree + ) + begin { $targets = [System.Collections.Generic.List[int]]::new() } + process { + if ($PSCmdlet.ParameterSetName -eq 'ById') { + foreach ($i in $Id) { [void]$targets.Add($i) } + } + else { + foreach ($n in $Name) { + $found = @(Get-Process -Name ($n -replace '\.exe$', '') -ErrorAction SilentlyContinue) + if (-not $found) { Write-Warning "No process matching: $n"; continue } + foreach ($p in $found) { [void]$targets.Add($p.Id) } + } + } + } + end { + if ($targets.Count -eq 0) { return } + $targets = $targets | Select-Object -Unique + foreach ($procId in $targets) { + $p = Get-Process -Id $procId -ErrorAction SilentlyContinue + if (-not $p) { Write-Host "PID $procId already gone." -ForegroundColor DarkGray; continue } + # Never escalate-kill a core OS process (would log out / bluescreen). PID <= 4 is System/Idle. + if ($procId -le 4 -or $script:ProtectedProcessNames -contains $p.ProcessName) { + Write-Warning "Refusing to stop protected system process: $($p.ProcessName) (PID $procId)." + continue + } + $label = "$($p.ProcessName) (PID $procId)" + if (-not $PSCmdlet.ShouldProcess($label, 'Stop process (escalating)')) { continue } + # Stage 1: Stop-Process -Force + try { Stop-Process -Id $procId -Force -ErrorAction Stop } catch { $null = $_ } + Start-Sleep -Milliseconds 200 + if (-not (Get-Process -Id $procId -ErrorAction SilentlyContinue)) { + Write-Host "Stopped: $label" -ForegroundColor Green + continue + } + # Stage 2: taskkill /F (with /T if requested) + $tkArgs = @('/F', '/PID', $procId) + if ($Tree) { $tkArgs += '/T' } + & taskkill.exe @tkArgs 2>&1 | Out-Null + Start-Sleep -Milliseconds 300 + if (-not (Get-Process -Id $procId -ErrorAction SilentlyContinue)) { + Write-Host "Force-killed: $label" -ForegroundColor Yellow + } + else { + Write-Error "Could not stop: $label. Likely needs SYSTEM privileges (protected process, antivirus, driver-held handle)." + } + } + } +} + +# Convenience: find what's holding a lock, kill those processes (aggressive), then delete. +# Backs off gracefully if the item still can't be removed (e.g. SYSTEM-held, in use by driver). +function Remove-LockedItem { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [Parameter(Mandatory, Position = 0)][string]$Path, + [switch]$Recurse, + [switch]$KillSystem # include PID 0/4 lockers (usually pointless, always dangerous) + ) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Path not found: $Path"; return } + if (-not $PSCmdlet.ShouldProcess($resolved.ProviderPath, 'Kill lockers and delete')) { return } + # Try a plain delete first. Fastest path when nothing is actually locking. + try { + Remove-Item -LiteralPath $resolved.ProviderPath -Recurse:$Recurse -Force -ErrorAction Stop + Write-Host "Deleted: $($resolved.ProviderPath)" -ForegroundColor Green + return + } + catch { + Write-Host "Initial delete failed, investigating lockers..." -ForegroundColor Yellow + } + $lockers = @(Find-FileLocker -Path $resolved.ProviderPath) + if (-not $lockers) { + Write-Error "Delete failed but no lockers reported. Check permissions or path." -ErrorAction Continue + return + } + Write-Host "Lockers found:" -ForegroundColor Cyan + $lockers | Format-Table PID, Name, WindowTitle | Out-Host + foreach ($l in $lockers) { + if (-not $KillSystem -and ($l.PID -eq 0 -or $l.PID -eq 4 -or $l.Name -eq 'System')) { + Write-Warning "Skipping system process $($l.Name) (PID $($l.PID)). Use -KillSystem to override (usually pointless)." + continue + } + Stop-StuckProcess -Id $l.PID -Tree -Confirm:$false + } + Start-Sleep -Milliseconds 500 + try { + Remove-Item -LiteralPath $resolved.ProviderPath -Recurse:$Recurse -Force -ErrorAction Stop + Write-Host "Deleted after unlocking: $($resolved.ProviderPath)" -ForegroundColor Green + } + catch { + Write-Error "Still locked after killing reported lockers: $($_.Exception.Message)" + Write-Host 'Next steps: reboot, or check for SYSTEM/driver lock via Sysinternals Handle/Process Explorer.' -ForegroundColor DarkGray + } +} + # Make HTTP requests with flexible options for method, body, headers, and content type. By default, it performs a GET request and attempts to parse JSON responses for pretty output. # It also handles binary responses gracefully and provides error details when requests fail. function http { @@ -2850,9 +4184,13 @@ function urldecode { # Measure execution time of a scriptblock function timer { param([Parameter(Mandatory)][scriptblock]$Command) + $oldTitle = Push-TabTitle 'timer' $sw = [System.Diagnostics.Stopwatch]::StartNew() - & $Command - $sw.Stop() + try { & $Command } + finally { + $sw.Stop() + Pop-TabTitle $oldTitle + } Write-Host ('Elapsed: {0:N3}s' -f $sw.Elapsed.TotalSeconds) -ForegroundColor Cyan } @@ -2916,10 +4254,15 @@ function portscan { foreach ($port in $Ports) { $tcp = New-Object System.Net.Sockets.TcpClient try { + $connected = $false $async = $tcp.BeginConnect($Hostname, $port, $null, $null) - $connected = $async.AsyncWaitHandle.WaitOne(500) -and $tcp.Connected - try { $tcp.EndConnect($async) } - catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }; $null = $_ } + # Only EndConnect once the connect has actually completed. Calling it after a WaitOne timeout blocks + # for the full OS SYN timeout (~20s), defeating the 500ms budget on every filtered port; Dispose() + # in finally aborts the still-pending attempt instead. + if ($async.AsyncWaitHandle.WaitOne(500)) { + try { $tcp.EndConnect($async); $connected = $tcp.Connected } + catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }; $connected = $false } + } if ($connected) { Write-Host (" {0,-6} open" -f $port) -ForegroundColor Green $open++ @@ -2940,7 +4283,7 @@ function ipinfo { param([string]$IpAddress) $url = if ($IpAddress) { "http://ip-api.com/json/$IpAddress" } else { "http://ip-api.com/json/" } try { - $info = Invoke-RestMethod -Uri $url -TimeoutSec 10 + $info = Invoke-RestMethod -Uri $url -TimeoutSec 10 -UseBasicParsing if (-not $info) { Write-Error "IP lookup returned no data."; return } if ($info.status -eq 'fail') { Write-Error "Lookup failed: $($info.message)"; return } Write-Host " IP: $($info.query)" -ForegroundColor White @@ -2971,21 +4314,27 @@ function watch { [Parameter(Mandatory)][scriptblock]$Command, [int]$Interval = 2 ) - Write-Host "Every ${Interval}s. Ctrl+C to stop." -ForegroundColor DarkGray - while ($true) { - Clear-Host - Write-Host ("watch: every {0}s | {1}" -f $Interval, (Get-Date -Format "HH:mm:ss")) -ForegroundColor DarkGray - Write-Host "" - try { & $Command } - catch { - if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } - Write-Host $_.Exception.Message -ForegroundColor Red - } - try { Start-Sleep -Seconds $Interval } - catch { - if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + $cmdLabel = ($Command.ToString().Trim() -replace '\s+', ' ') + if ($cmdLabel.Length -gt 40) { $cmdLabel = $cmdLabel.Substring(0, 40) + '...' } + $oldTitle = Push-TabTitle "watch: $cmdLabel" + try { + Write-Host "Every ${Interval}s. Ctrl+C to stop." -ForegroundColor DarkGray + while ($true) { + Clear-Host + Write-Host ("watch: every {0}s | {1}" -f $Interval, (Get-Date -Format "HH:mm:ss")) -ForegroundColor DarkGray + Write-Host "" + try { & $Command } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Host $_.Exception.Message -ForegroundColor Red + } + try { Start-Sleep -Seconds $Interval } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + } } } + finally { Pop-TabTitle $oldTitle } } # WHOIS domain lookup via RDAP (IANA standard, no external tools needed) @@ -2993,7 +4342,7 @@ function whois { param([Parameter(Mandatory)][string]$Domain) $Domain = $Domain -replace '^https?://', '' -replace '/.*$', '' try { - $rdap = Invoke-RestMethod -Uri "https://rdap.org/domain/$Domain" -TimeoutSec 10 + $rdap = Invoke-RestMethod -Uri "https://rdap.org/domain/$Domain" -TimeoutSec 10 -UseBasicParsing Write-Host " Domain: $($rdap.ldhName)" -ForegroundColor White Write-Host " Status: $(@($rdap.status) -join ', ')" -ForegroundColor White if ($rdap.entities) { @@ -3012,7 +4361,9 @@ function whois { default { $ev.eventAction } } if ($label) { - $date = ([DateTime]$ev.eventDate).ToString('yyyy-MM-dd') + # Parse each event date defensively: a single non-ISO eventDate from some registrar must not + # abort the whole lookup (losing nameservers and the other events). Fall back to the raw value. + $date = try { ([DateTime]$ev.eventDate).ToString('yyyy-MM-dd') } catch { [string]$ev.eventDate } Write-Host " ${label}:$((' ' * [math]::Max(1, 12 - $label.Length)))$date" -ForegroundColor White } } @@ -3058,35 +4409,1134 @@ function Invoke-Clipboard { } Set-Alias -Name icb -Value Invoke-Clipboard -# Enhanced PSReadLine Configuration -$PSReadLineOptions = @{ - EditMode = 'Windows' - HistoryNoDuplicates = $true - HistorySearchCursorMovesToEnd = $true - Colors = @{ - Command = '#61AFEF' # Blue - Parameter = '#98C379' # Green - Operator = '#56B6C2' # Cyan - Variable = '#E5C07B' # Yellow - String = '#98C379' # Green - Number = '#D19A66' # Orange - Type = '#61AFEF' # Blue - Comment = '#5C6370' # Gray - Keyword = '#C678DD' # Soft purple - Error = '#E06C75' # Red +# Argument completers (tab-complete for custom commands) + +# Common ports for fwallow/fwblock -Port +Register-ArgumentCompleter -CommandName fwallow, fwblock -ParameterName Port -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + @(22, 53, 80, 139, 143, 443, 445, 465, 587, 993, 995, 1194, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 27017) | + Where-Object { [string]$_ -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new([string]$_, [string]$_, 'ParameterValue', [string]$_) } +} + +# Available Windows event logs for journal -LogName +Register-ArgumentCompleter -CommandName journal -ParameterName LogName -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + try { + Get-WinEvent -ListLog * -ErrorAction SilentlyContinue | + Where-Object { $_.LogName -like "$wordToComplete*" } | + Select-Object -First 25 | + ForEach-Object { [System.Management.Automation.CompletionResult]::new("'$($_.LogName)'", $_.LogName, 'ParameterValue', $_.LogName) } } - BellStyle = 'None' + catch { $null = $_ } } -Set-PSReadLineOption @PSReadLineOptions -# PSReadLine features that require an interactive console host -if ($isInteractive -and (Get-Module PSReadLine)) { - # Core-only prediction settings (PredictionSource/PredictionViewStyle don't exist on Desktop) - # Guard against hosts without VT support (e.g. agent terminals, redirected output) - if ($PSVersionTable.PSEdition -eq "Core") { - $supportsPrediction = $false - try { - $supportsPrediction = [bool]$Host.UI.SupportsVirtualTerminal -and -not [Console]::IsOutputRedirected +# Common gitignore.io templates for gitignore +Register-ArgumentCompleter -CommandName gitignore -ParameterName Language -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + @('python', 'node', 'go', 'rust', 'java', 'windows', 'visualstudio', 'macos', 'linux', + 'vim', 'emacs', 'vscode', 'pycharm', 'intellij', 'sublimetext', 'ruby', 'swift', + 'kotlin', 'scala', 'cpp', 'c', 'dotnetcore', 'android', 'ios', 'unity', 'unreal', + 'jekyll', 'wordpress', 'laravel', 'django', 'flask', 'terraform', 'ansible', + 'docker', 'kubernetes', 'svelte', 'nextjs', 'gatsby', 'angular', 'react') | + Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } +} + +# Nmap modes for nscan -Mode (redundant with ValidateSet but gives richer descriptions) +Register-ArgumentCompleter -CommandName nscan -ParameterName Mode -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + $modes = @{ + 'Quick' = 'Top ports with aggressive timing' + 'Full' = 'All 65535 ports' + 'Services' = 'Service and default-script detection' + 'Stealth' = 'SYN scan with fragmentation' + 'Vuln' = 'Vuln NSE scripts + service detection' + 'Ports' = 'Custom -Ports list' + } + $modes.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Key, $_.Key, 'ParameterValue', $_.Value) } +} + +# Defender scan modes +Register-ArgumentCompleter -CommandName defscan -ParameterName Mode -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + @('Quick', 'Full') | Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } +} + +# Lint mode presets +Register-ArgumentCompleter -CommandName lint -ParameterName Mode -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + $modes = @{ + 'Standard' = 'Default PSScriptAnalyzer rule set' + 'Strict' = 'Include Information-level issues' + 'Security' = 'Security-relevant rules only' + 'CI' = 'Match the project CI ExcludeRule list' + } + $modes.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Key, $_.Key, 'ParameterValue', $_.Value) } +} + +# Existing trusted directories for Remove-TrustedDirectory -Path +Register-ArgumentCompleter -CommandName Remove-TrustedDirectory -ParameterName Path -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + if (-not $script:PSP) { return } + $script:PSP.TrustedDirs | Where-Object { $_ -like "*$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new("'$_'", $_, 'ParameterValue', $_) } +} + +# Set-TerminalBackground: StretchMode / Alignment with human-readable descriptions +Register-ArgumentCompleter -CommandName Set-TerminalBackground -ParameterName StretchMode -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + $modes = @{ + 'none' = 'Original image size (no stretch)' + 'fill' = 'Stretch to fill, ignore aspect ratio' + 'uniform' = 'Scale to fit while preserving aspect' + 'uniformToFill' = 'Scale to fill while preserving aspect (crop)' + } + $modes.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Key, $_.Key, 'ParameterValue', $_.Value) } +} + +Register-ArgumentCompleter -CommandName Set-TerminalBackground -ParameterName Alignment -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + @('center', 'left', 'top', 'right', 'bottom', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight') | + Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } +} + +# WSL distro name completion for all WSL helpers that take a -Distro parameter. +# Calls Get-WslDistro at completion time; silently returns nothing if wsl.exe is unavailable. +Register-ArgumentCompleter -CommandName Enter-WslHere, ConvertTo-WslPath, ConvertTo-WindowsPath, Stop-Wsl, Get-WslIp, Get-WslFile, Show-WslTree, Open-WslExplorer -ParameterName Distro -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + if (-not (Get-Command Get-WslDistro -ErrorAction SilentlyContinue)) { return } + try { + Get-WslDistro | Where-Object { $_.Name -like "$wordToComplete*" } | + ForEach-Object { + $tip = "$($_.State), WSL$($_.Version)" + $(if ($_.Default) { ' (default)' } else { '' }) + [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $tip) + } + } + catch { $null = $_ } +} + +# Stop-StuckProcess -Name: live list of running processes (unique names). +Register-ArgumentCompleter -CommandName Stop-StuckProcess -ParameterName Name -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + try { + Get-Process -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty ProcessName -Unique | + Where-Object { $_ -like "$wordToComplete*" } | + Sort-Object | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } + } + catch { $null = $_ } +} + +# Register-ProfileHook -EventName completion +Register-ArgumentCompleter -CommandName Register-ProfileHook -ParameterName EventName -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters + $events = @{ + 'OnProfileLoad' = 'Fires once after profile has loaded' + 'PrePrompt' = 'Fires before every prompt render' + 'OnCd' = 'Fires when the current directory changes' + } + $events.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Key, $_.Key, 'ParameterValue', $_.Value) } +} + +# Sysadmin / Linux-feel + +# Tail Windows Event Log in journalctl style. Default: last 50 System events. +# -Follow polls every 2s for new entries. -Level filters by severity name. +function journal { + param( + [string]$LogName = 'System', + [int]$Count = 50, + [switch]$Follow, + [ValidateSet('Critical', 'Error', 'Warning', 'Information', 'Verbose')] + [string]$Level + ) + $filter = @{ LogName = $LogName } + if ($Level) { + $filter.Level = switch ($Level) { + 'Critical' { 1 } 'Error' { 2 } 'Warning' { 3 } 'Information' { 4 } 'Verbose' { 5 } + } + } + function Write-JournalLine { + param($Entry) + $color = switch ($Entry.LevelDisplayName) { + 'Critical' { 'Red' } 'Error' { 'Red' } 'Warning' { 'Yellow' } default { 'Gray' } + } + $msg = ($Entry.Message -replace "`r?`n", ' ') + if ($msg.Length -gt 200) { $msg = $msg.Substring(0, 200) + '...' } + Write-Host ("{0} [{1,-11}] {2}: {3}" -f $Entry.TimeCreated, $Entry.LevelDisplayName, $Entry.ProviderName, $msg) -ForegroundColor $color + } + $events = Get-WinEvent -FilterHashtable $filter -MaxEvents $Count -ErrorAction SilentlyContinue + if (-not $events) { Write-Host "No events matched." -ForegroundColor Yellow; return } + $events | Sort-Object TimeCreated | ForEach-Object { Write-JournalLine $_ } + if (-not $Follow) { return } + Write-Host "Following $LogName. Ctrl+C to stop." -ForegroundColor DarkGray + $lastTime = ($events | Sort-Object TimeCreated | Select-Object -Last 1).TimeCreated + $oldTitle = Push-TabTitle "journal: $LogName" + try { + while ($true) { + Start-Sleep -Seconds 2 + $follow = $filter.Clone() + $follow.StartTime = $lastTime.AddMilliseconds(1) + $newEvents = Get-WinEvent -FilterHashtable $follow -ErrorAction SilentlyContinue + if ($newEvents) { + foreach ($entry in ($newEvents | Sort-Object TimeCreated)) { + Write-JournalLine $entry + $lastTime = $entry.TimeCreated + } + } + } + } + finally { Pop-TabTitle $oldTitle } +} + +# List disks and partitions in a pretty tree (Linux lsblk equivalent). +function lsblk { + $disks = Get-Disk -ErrorAction SilentlyContinue | Sort-Object Number + if (-not $disks) { Write-Warning "Get-Disk returned no disks."; return } + foreach ($disk in $disks) { + $sizeGB = [math]::Round($disk.Size / 1GB, 1) + Write-Host ("Disk {0}: {1} ({2} GB, {3})" -f $disk.Number, $disk.FriendlyName, $sizeGB, $disk.BusType) -ForegroundColor Cyan + $partitions = Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue | Sort-Object PartitionNumber + foreach ($p in $partitions) { + $pSize = [math]::Round($p.Size / 1GB, 1) + $letter = if ($p.DriveLetter) { ('{0}:' -f $p.DriveLetter) } else { '' } + $label = '' + if ($p.DriveLetter) { + $vol = Get-Volume -DriveLetter $p.DriveLetter -ErrorAction SilentlyContinue + if ($vol) { + $usedGB = [math]::Round(($vol.Size - $vol.SizeRemaining) / 1GB, 1) + $label = ("{0} [{1}, {2}/{3} GB]" -f $vol.FileSystemLabel, $vol.FileSystem, $usedGB, $pSize) + } + } + Write-Host (" {0,-3} {1,-6} {2,7} GB {3,-22} {4}" -f $p.PartitionNumber, $letter, $pSize, $p.Type, $label) + } + } +} + +# Interactive process viewer. Prefers btop/ntop/htop if installed, otherwise falls back +# to the existing 'svc -Live' helper so the command always does something useful. +function htop { + foreach ($c in @('btop', 'ntop', 'htop')) { + $cmd = Get-Command $c -ErrorAction SilentlyContinue + if ($cmd) { & $cmd.Source; return } + } + Write-Host 'No TUI process viewer installed. Tip: winget install aristocratos.btop4win' -ForegroundColor Yellow + Write-Host 'Falling back to svc -Live.' -ForegroundColor DarkGray + svc -Live +} + +# Combined traceroute + per-hop ping (my traceroute). -Count pings per hop (default 3). +function mtr { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Target, + [ValidateRange(1, 50)][int]$Count = 3, + [ValidateRange(1, 64)][int]$MaxHops = 30 + ) + $oldTitle = Push-TabTitle "mtr: $Target" + try { + Write-Host "Tracing route to $Target..." -ForegroundColor Cyan + $trace = Test-NetConnection $Target -TraceRoute -WarningAction SilentlyContinue -ErrorAction SilentlyContinue + if (-not $trace -or -not $trace.TraceRoute) { Write-Error "Traceroute failed for $Target"; return } + Write-Host (" {0,-4} {1,-20} {2,7} {3,7} {4,7} Samples" -f 'Hop', 'Address', 'Loss%', 'Avg', 'Best') -ForegroundColor DarkGray + $i = 0 + foreach ($hop in @($trace.TraceRoute | Where-Object { $_ })) { + $i++ + if ($i -gt $MaxHops) { break } + $times = @() + $lost = 0 + for ($j = 0; $j -lt $Count; $j++) { + try { + $p = Test-Connection -ComputerName $hop -Count 1 -ErrorAction Stop + if ($p) { + $lat = if ($p.PSObject.Properties['ResponseTime']) { [int]$p.ResponseTime } elseif ($p.PSObject.Properties['Latency']) { [int]$p.Latency } else { 0 } + $times += $lat + } + else { $lost++ } + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + $lost++ + } + } + $lossPct = [math]::Round(($lost / $Count) * 100, 0) + $avg = if ($times.Count) { [math]::Round(($times | Measure-Object -Average).Average, 0) } else { '-' } + $best = if ($times.Count) { ($times | Measure-Object -Minimum).Minimum } else { '-' } + $color = if ($lossPct -gt 20) { 'Red' } elseif ($lossPct -gt 5) { 'Yellow' } else { 'Green' } + Write-Host (" {0,-4} {1,-20} {2,6}% {3,7} {4,7} {5}" -f $i, $hop, $lossPct, $avg, $best, $times.Count) -ForegroundColor $color + } + } + finally { Pop-TabTitle $oldTitle } +} + +# Quick Windows Firewall allow rule. Requires elevation and confirms before changing +# global firewall state unless the caller passes -Confirm:$false. +function fwallow { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [Parameter(Mandatory)][string]$Name, + [int]$Port, + [ValidateSet('TCP', 'UDP', 'Any')][string]$Protocol = 'TCP', + [ValidateSet('Inbound', 'Outbound')][string]$Direction = 'Inbound' + ) + if (-not $isAdmin) { Write-Error 'Firewall changes require an elevated shell.'; return } + $splat = @{ DisplayName = $Name; Direction = $Direction; Action = 'Allow'; Protocol = $Protocol } + if ($Port) { $splat.LocalPort = $Port } + $portText = if ($Port) { " port $Port" } else { '' } + if (-not $PSCmdlet.ShouldProcess($Name, "Add firewall allow rule ($Direction $Protocol$portText)")) { return } + New-NetFirewallRule @splat | Out-Null + Write-Host ("Allow rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Green +} + +# Quick Windows Firewall block rule. Requires elevation and confirms before changing +# global firewall state unless the caller passes -Confirm:$false. +function fwblock { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [Parameter(Mandatory)][string]$Name, + [int]$Port, + [ValidateSet('TCP', 'UDP', 'Any')][string]$Protocol = 'TCP', + [ValidateSet('Inbound', 'Outbound')][string]$Direction = 'Inbound' + ) + if (-not $isAdmin) { Write-Error 'Firewall changes require an elevated shell.'; return } + $splat = @{ DisplayName = $Name; Direction = $Direction; Action = 'Block'; Protocol = $Protocol } + if ($Port) { $splat.LocalPort = $Port } + $portText = if ($Port) { " port $Port" } else { '' } + if (-not $PSCmdlet.ShouldProcess($Name, "Add firewall block rule ($Direction $Protocol$portText)")) { return } + New-NetFirewallRule @splat | Out-Null + Write-Host ("Block rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Yellow +} + +# Cybersec + +# Nmap wrapper with curated scan profiles. Mode 'Ports' uses -Ports list. +function nscan { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Target, + [ValidateSet('Quick', 'Full', 'Services', 'Stealth', 'Vuln', 'Ports')] + [string]$Mode = 'Quick', + [int[]]$Ports + ) + if (-not (Get-Command nmap -ErrorAction SilentlyContinue)) { + Write-Error 'nmap is not installed. Install with: winget install Insecure.Nmap' + return + } + $nmapArgs = switch ($Mode) { + 'Quick' { @('-F', '-T4') } + 'Full' { @('-p-', '-T4') } + 'Services' { @('-sV', '-sC', '--top-ports', '1000') } + 'Stealth' { @('-sS', '-T2', '-f') } + 'Vuln' { @('--script', 'vuln', '-sV') } + 'Ports' { @() } + } + if ($Mode -eq 'Ports' -and $Ports) { $nmapArgs += @('-p', ($Ports -join ',')) } + Write-Host ("nmap {0} {1}" -f ($nmapArgs -join ' '), $Target) -ForegroundColor DarkGray + $oldTitle = Push-TabTitle "nscan $Mode`: $Target" + try { nmap @nmapArgs $Target } + finally { Pop-TabTitle $oldTitle } +} + +# Authenticode signature inspector. Accepts file or directory. Reports status, signer, expiry. +function sigcheck { + param([Parameter(Mandatory)][string]$Path) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Path not found: $Path"; return } + $targets = if ((Get-Item $resolved).PSIsContainer) { + Get-ChildItem -LiteralPath $resolved -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -match '^\.(exe|dll|sys|ps1|psm1|psd1|msi|cat|ocx|cpl)$' } + } + else { Get-Item -LiteralPath $resolved } + foreach ($t in $targets) { + $sig = Get-AuthenticodeSignature -LiteralPath $t.FullName -ErrorAction SilentlyContinue + if (-not $sig) { continue } + $color = switch ($sig.Status) { + 'Valid' { 'Green' } 'NotSigned' { 'DarkGray' } default { 'Red' } + } + Write-Host '' + Write-Host ("File: {0}" -f $t.FullName) -ForegroundColor Cyan + Write-Host ("Status: {0}" -f $sig.Status) -ForegroundColor $color + if ($sig.StatusMessage) { Write-Host ("Message: {0}" -f $sig.StatusMessage) -ForegroundColor DarkGray } + if ($sig.SignerCertificate) { + Write-Host ("Signer: {0}" -f $sig.SignerCertificate.Subject) + Write-Host ("Issuer: {0}" -f $sig.SignerCertificate.Issuer) + Write-Host ("Expires: {0}" -f $sig.SignerCertificate.NotAfter) + Write-Host ("Thumb: {0}" -f $sig.SignerCertificate.Thumbprint) -ForegroundColor DarkGray + } + if ($sig.TimeStamperCertificate) { + Write-Host ("Timestamp: {0}" -f $sig.TimeStamperCertificate.Subject) -ForegroundColor DarkGray + } + } +} + +# List NTFS alternate data streams (a classic malware hiding spot). Works on files or dirs. +function ads { + param([Parameter(Mandatory)][string]$Path) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Path not found: $Path"; return } + $items = if ((Get-Item $resolved).PSIsContainer) { + Get-ChildItem -LiteralPath $resolved -Recurse -File -ErrorAction SilentlyContinue + } + else { Get-Item -LiteralPath $resolved } + # Emit structured objects so callers can filter/select/measure. PS's default formatter + # still renders a human-readable table in the interactive case. + foreach ($item in $items) { + $streams = Get-Item -LiteralPath $item.FullName -Stream * -ErrorAction SilentlyContinue | + Where-Object { $_.Stream -ne ':$DATA' } + foreach ($s in $streams) { + [PSCustomObject]@{ + File = $item.FullName + Stream = $s.Stream + Length = $s.Length + } + } + } +} + +# Windows Defender scan wrapper. No path = Quick (or Full with -Mode Full). With path = custom. +function defscan { + [CmdletBinding()] + param( + [Parameter(Position = 0)][string]$Path, + [ValidateSet('Quick', 'Full')][string]$Mode = 'Quick' + ) + if (-not (Get-Command Start-MpScan -ErrorAction SilentlyContinue)) { + Write-Error 'Windows Defender cmdlets not available on this system.' + return + } + $titleLabel = if ($Path) { "defscan: $Path" } else { "defscan: $Mode" } + $oldTitle = Push-TabTitle $titleLabel + try { + if ($Path) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Path not found: $Path"; return } + Write-Host ("Custom scan: {0}" -f $resolved.Path) -ForegroundColor Cyan + Start-MpScan -ScanType CustomScan -ScanPath $resolved.Path + } + else { + $scanType = if ($Mode -eq 'Full') { 'FullScan' } else { 'QuickScan' } + Write-Host ("Running {0}..." -f $scanType) -ForegroundColor Cyan + Start-MpScan -ScanType $scanType + } + $threats = Get-MpThreat -ErrorAction SilentlyContinue + if ($threats) { + Write-Host 'Recent threats:' -ForegroundColor Yellow + $threats | Select-Object -First 10 | Format-Table ThreatName, SeverityID, DetectionID -AutoSize + } + else { Write-Host 'No threats recorded.' -ForegroundColor Green } + } + finally { Pop-TabTitle $oldTitle } +} + +# HaveIBeenPwned k-anonymity password check. Only first 5 SHA1 chars leave the machine. +function pwnd { + param([Parameter(Mandatory)][string]$Candidate) + $sha1 = [System.Security.Cryptography.SHA1]::Create() + try { + $hashBytes = $sha1.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Candidate)) + $hashHex = [BitConverter]::ToString($hashBytes).Replace('-', '').ToUpper() + } + finally { $sha1.Dispose() } + $prefix = $hashHex.Substring(0, 5) + $suffix = $hashHex.Substring(5) + try { + $response = Invoke-RestMethod -Uri "https://api.pwnedpasswords.com/range/$prefix" -Headers @{ 'Add-Padding' = 'true' } -TimeoutSec 10 -UseBasicParsing + $lines = $response -split "`r?`n" + $match = $lines | Where-Object { $_ -match ('^' + [regex]::Escape($suffix) + ':(\d+)') } + if ($match) { + $count = [int](($match -split ':')[1]) + if ($count -gt 0) { + Write-Host ("PWNED: seen in {0} breach(es). Do not use this password." -f $count) -ForegroundColor Red + return + } + } + Write-Host 'Safe: password not found in known breaches.' -ForegroundColor Green + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "HIBP lookup failed: $_" + } +} + +# Full TLS cert probe: protocol, cipher, chain, SAN, SHA256 pin. Extends tlscert. +function certcheck { + param( + [Parameter(Mandatory)][string]$Domain, + [int]$Port = 443 + ) + $tcp = $null; $ssl = $null; $chain = $null + try { + $tcp = [System.Net.Sockets.TcpClient]::new() + $async = $tcp.BeginConnect($Domain, $Port, $null, $null) + if (-not $async.AsyncWaitHandle.WaitOne(5000)) { throw "Connection to ${Domain}:${Port} timed out" } + $tcp.EndConnect($async) + $stream = $tcp.GetStream() + $stream.ReadTimeout = 10000 + $ssl = [System.Net.Security.SslStream]::new($stream, $false, { $true }) + $ssl.AuthenticateAsClient($Domain) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ssl.RemoteCertificate) + $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() + $chainValid = $chain.Build($cert) + $daysLeft = [math]::Floor(($cert.NotAfter - (Get-Date)).TotalDays) + $expiryColor = if ($daysLeft -lt 30) { 'Red' } elseif ($daysLeft -lt 90) { 'Yellow' } else { 'Green' } + Write-Host ("Host: {0}:{1}" -f $Domain, $Port) -ForegroundColor Cyan + Write-Host ("TLS: {0}" -f $ssl.SslProtocol) + Write-Host ("Cipher: {0} {1} bits" -f $ssl.CipherAlgorithm, $ssl.CipherStrength) + Write-Host ("Subject: {0}" -f $cert.Subject) + Write-Host ("Issuer: {0}" -f $cert.Issuer) + Write-Host ("Serial: {0}" -f $cert.SerialNumber) -ForegroundColor DarkGray + Write-Host ("SHA256 pin: {0}" -f $cert.GetCertHashString('SHA256')) -ForegroundColor DarkGray + Write-Host ("Not before: {0}" -f $cert.NotBefore) + Write-Host ("Not after: {0}" -f $cert.NotAfter) + Write-Host ("Days left: {0}" -f $daysLeft) -ForegroundColor $expiryColor + Write-Host ("Chain valid: {0}" -f $chainValid) -ForegroundColor $(if ($chainValid) { 'Green' } else { 'Red' }) + $sanExt = $cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } | Select-Object -First 1 + if ($sanExt) { + $san = ($sanExt.Format($false) -replace 'DNS Name=', '' -replace ',\s*', ', ') + Write-Host ("SAN: {0}" -f $san) -ForegroundColor DarkGray + } + Write-Host 'Chain:' -ForegroundColor DarkGray + foreach ($element in $chain.ChainElements) { + $ec = $element.Certificate + Write-Host (" {0} (exp {1})" -f $ec.Subject, $ec.NotAfter.ToString('yyyy-MM-dd')) -ForegroundColor DarkGray + } + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "certcheck failed for ${Domain}:${Port} - $_" + } + finally { + if ($chain) { $chain.Dispose() } + if ($ssl) { $ssl.Dispose() } + if ($tcp) { $tcp.Dispose() } + } +} + +# Shannon entropy of file bytes (0.0 = uniform, 8.0 = random). High values hint at packing/encryption. +function entropy { + param([Parameter(Mandatory)][string]$Path) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "File not found: $Path"; return } + $bytes = [System.IO.File]::ReadAllBytes($resolved.Path) + if ($bytes.Length -eq 0) { Write-Host 'Empty file.' -ForegroundColor Yellow; return } + $counts = New-Object 'int[]' 256 + foreach ($b in $bytes) { $counts[$b]++ } + $e = 0.0 + foreach ($c in $counts) { + if ($c -gt 0) { + $p = $c / $bytes.Length + $e -= $p * [math]::Log($p, 2) + } + } + $label = if ($e -gt 7.5) { 'very high (packed/encrypted)' } + elseif ($e -gt 6.5) { 'high (compressed)' } + elseif ($e -gt 4.5) { 'medium (code/data)' } + else { 'low (plain text)' } + $color = if ($e -gt 7.5) { 'Red' } elseif ($e -gt 6.5) { 'Yellow' } else { 'Green' } + Write-Host ("File: {0} ({1} bytes)" -f (Split-Path $resolved.Path -Leaf), $bytes.Length) -ForegroundColor Cyan + Write-Host ("Entropy: {0:N3} / 8.000 ({1})" -f $e, $label) -ForegroundColor $color +} + +# Developer + +# One-line HTTP server for current (or given) directory. Prefers python -m http.server, then npx. +function serve { + param( + [int]$Port = 8000, + [string]$Path = '.' + ) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Path not found: $Path"; return } + $oldTitle = Push-TabTitle "serve :$Port" + try { + if (Get-Command python -ErrorAction SilentlyContinue) { + Write-Host ("Serving {0} on http://127.0.0.1:{1} (Ctrl+C to stop)" -f $resolved.Path, $Port) -ForegroundColor Cyan + Push-Location $resolved.Path + try { & python -m http.server $Port } + finally { Pop-Location } + return + } + if (Get-Command npx -ErrorAction SilentlyContinue) { + Write-Host ("Serving {0} via npx http-server on http://127.0.0.1:{1}" -f $resolved.Path, $Port) -ForegroundColor Cyan + Push-Location $resolved.Path + try { & npx --yes http-server -p $Port } + finally { Pop-Location } + return + } + Write-Error 'Install python or node/npx first. E.g. winget install Python.Python.3.12' + } + finally { Pop-TabTitle $oldTitle } +} + +# Generate a .gitignore from toptal.com/developers/gitignore/api. Multiple languages allowed. +function gitignore { + param([Parameter(Mandatory, ValueFromRemainingArguments)][string[]]$Language) + $joined = ($Language -join ',').ToLower() + try { + $content = Invoke-RestMethod -Uri "https://www.toptal.com/developers/gitignore/api/$joined" -TimeoutSec 15 -UseBasicParsing + if (-not $content) { Write-Error 'Empty response from gitignore service.'; return } + $target = Join-Path (Get-Location) '.gitignore' + if (Test-Path $target) { + $backup = "$target.$(Get-Date -Format 'yyyyMMdd-HHmmss').bak" + Copy-Item $target $backup + Write-Host ("Backed up existing .gitignore to {0}" -f $backup) -ForegroundColor DarkGray + } + [System.IO.File]::WriteAllText($target, $content, [System.Text.UTF8Encoding]::new($false)) + Write-Host ("Wrote .gitignore for: {0}" -f $joined) -ForegroundColor Green + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "gitignore fetch failed: $_" + } +} + +# Fuzzy git branch checkout using fzf. Handles local and remote branches. +function gcof { + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error 'git is not installed'; return } + if (-not (Get-Command fzf -ErrorAction SilentlyContinue)) { Write-Error 'fzf is not installed (winget install junegunn.fzf)'; return } + $branches = git branch --all --format='%(refname:short)' 2>$null | Where-Object { $_ -and $_ -notmatch '^origin/HEAD' } + if (-not $branches) { Write-Error 'No branches found'; return } + $selected = $branches | fzf --height 40% --reverse --prompt 'checkout> ' + if ($selected) { + $clean = ($selected -replace '^origin/', '').Trim() + git checkout $clean + } +} + +# Load a .env file into the current session. Handles export prefix and quoted values. +function envload { + param([string]$Path = '.env') + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "File not found: $Path"; return } + $loaded = 0 + Get-Content $resolved.Path | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + if ($line -match '^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$') { + $k = $matches[1]; $v = $matches[2].Trim() + if ($v.Length -ge 2 -and $v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) } + elseif ($v.Length -ge 2 -and $v.StartsWith("'") -and $v.EndsWith("'")) { $v = $v.Substring(1, $v.Length - 2) } + Set-Item -LiteralPath "env:$k" -Value $v + $loaded++ + } + } + Write-Host ("Loaded {0} variable(s) from {1}" -f $loaded, $resolved.Path) -ForegroundColor Green +} + +# Quick command-example lookup via tldr-pages. Uses native tldr client if installed. +function tldr { + if (Get-Command tldr.exe -ErrorAction SilentlyContinue) { & tldr.exe @args; return } + if (-not $args) { Write-Error 'Usage: tldr '; return } + $cmd = ($args[0]).ToString().ToLower() + foreach ($platform in @('common', 'windows', 'linux', 'osx')) { + try { + $url = "https://raw.githubusercontent.com/tldr-pages/tldr/main/pages/$platform/$cmd.md" + $page = Invoke-RestMethod -Uri $url -TimeoutSec 10 -ErrorAction Stop -UseBasicParsing + Write-Host $page -ForegroundColor White + return + } + catch { continue } + } + Write-Error "No tldr page found for '$cmd'. Install native client: winget install tldr-pages.tldr" +} + +# Run a scriptblock N times. -UntilSuccess stops early on zero exit code. +function repeat { + param( + [Parameter(Mandatory, Position = 0)][ValidateRange(1, 10000)][int]$Count, + [Parameter(Mandatory, Position = 1)][scriptblock]$Command, + [switch]$UntilSuccess, + [int]$DelaySeconds = 0 + ) + # Save the title ONCE; we re-Push on every iteration to update the counter in-place + # and restore to the original on exit. Cheaper than Push/Pop per iteration. + $originalTitle = $null + try { $originalTitle = $Host.UI.RawUI.WindowTitle } catch { $null = $_ } + try { + for ($i = 1; $i -le $Count; $i++) { + try { $Host.UI.RawUI.WindowTitle = "repeat $i/$Count" } catch { $null = $_ } + Write-Host ("[{0}/{1}]" -f $i, $Count) -ForegroundColor DarkGray + # Clear $LASTEXITCODE so a stale value from before this loop cannot cause -UntilSuccess + # to terminate on iteration 1 when the scriptblock contains no native commands. + $global:LASTEXITCODE = $null + $threwError = $false + try { + & $Command + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Host $_.Exception.Message -ForegroundColor Red + $threwError = $true + } + if ($UntilSuccess -and -not $threwError -and ($null -eq $LASTEXITCODE -or $LASTEXITCODE -eq 0)) { + Write-Host ("Success on attempt {0}." -f $i) -ForegroundColor Green + return + } + if ($DelaySeconds -gt 0 -and $i -lt $Count) { Start-Sleep -Seconds $DelaySeconds } + } + if ($UntilSuccess) { Write-Warning ("Command did not succeed in {0} attempts." -f $Count) } + } + finally { Pop-TabTitle $originalTitle } +} + +# Create a Python venv and activate it. Default folder: .venv. +function mkvenv { + param( + [string]$Name = '.venv', + [string]$PythonPath + ) + $python = if ($PythonPath) { $PythonPath } + elseif (Get-Command python -ErrorAction SilentlyContinue) { 'python' } + elseif (Get-Command py -ErrorAction SilentlyContinue) { 'py' } + else { $null } + if (-not $python) { Write-Error 'python not found on PATH (winget install Python.Python.3.12)'; return } + Write-Host ("Creating venv at ./{0}..." -f $Name) -ForegroundColor Cyan + & $python -m venv $Name + if ($LASTEXITCODE -ne 0) { Write-Error 'venv creation failed'; return } + $venvRoot = Resolve-Path -LiteralPath $Name + $activate = Join-Path $venvRoot 'Scripts\Activate.ps1' + if (-not (Test-Path $activate)) { $activate = Join-Path $venvRoot 'bin/Activate.ps1' } + if (Test-Path $activate) { + . $activate + Write-Host ("Activated {0}." -f $Name) -ForegroundColor Green + } + else { Write-Warning "Created $Name but Activate.ps1 not found." } +} + +# Detection / AST (inspired by vscode-powershell language service) + +# Parse a .ps1 file and emit Function/Alias outline entries as objects. Pipe-friendly: +# `outline file.ps1 | Where Kind -eq Function`, `outline | Format-Table`, etc. +function outline { + param([Parameter(Mandatory)][string]$Path) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "File not found: $Path"; return } + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($resolved.Path, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors -and $parseErrors.Count -gt 0) { + foreach ($err in $parseErrors) { + Write-Warning ("L{0}: {1}" -f $err.Extent.StartLineNumber, $err.Message) + } + } + $fns = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) + foreach ($f in $fns) { + $paramNames = @() + if ($f.Parameters) { $paramNames = $f.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath } } + elseif ($f.Body -and $f.Body.ParamBlock) { $paramNames = $f.Body.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath } } + [PSCustomObject]@{ + Kind = 'Function' + Line = $f.Extent.StartLineNumber + Name = $f.Name + Params = ($paramNames -join ', ') + } + } + $raw = Get-Content $resolved.Path -Raw + $aliases = [regex]::Matches($raw, 'Set-Alias\s+-Name\s+(\S+)\s+-Value\s+(\S+)') + foreach ($m in $aliases) { + [PSCustomObject]@{ + Kind = 'Alias' + Line = $null + Name = $m.Groups[1].Value + Params = "-> $($m.Groups[2].Value)" + } + } +} + +# AST-based symbol search across .ps1 files. Regex matches on function names. +function psym { + param( + [Parameter(Position = 0)][string]$Pattern, + [Parameter(Position = 1)][string]$Root = '.' + ) + $resolved = Resolve-Path -LiteralPath $Root -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Root not found: $Root"; return } + $results = [System.Collections.Generic.List[object]]::new() + Get-ChildItem -Path $resolved -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + $tokens = $null; $parseErrors = $null + $ast = $null + try { $ast = [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$parseErrors) } + catch { return } + if (-not $ast) { return } + $fns = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) + foreach ($f in $fns) { + if ([string]::IsNullOrEmpty($Pattern) -or $f.Name -match $Pattern) { + $results.Add([PSCustomObject]@{ + Name = $f.Name + Line = $f.Extent.StartLineNumber + File = $_.FullName + }) + } + } + } + if ($results.Count -eq 0) { Write-Host 'No matches.' -ForegroundColor Yellow; return } + $results | Sort-Object Name | Format-Table Name, Line, File -AutoSize +} + +# PSScriptAnalyzer wrapper with useful presets. -Fix applies auto-fixes where possible. +function lint { + [CmdletBinding()] + param( + [Parameter(Position = 0)][string]$Path = '.', + [ValidateSet('Standard', 'Strict', 'Security', 'CI')][string]$Mode = 'Standard', + [switch]$Fix + ) + if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) { + Write-Error 'PSScriptAnalyzer not installed. Install-Module PSScriptAnalyzer -Scope CurrentUser' + return + } + Import-Module PSScriptAnalyzer -ErrorAction SilentlyContinue + $splat = @{ Path = $Path; Recurse = $true } + switch ($Mode) { + 'Strict' { $splat.Severity = @('Error', 'Warning', 'Information') } + 'Security' { + $splat.IncludeRule = @( + 'PSAvoidUsingPlainTextForPassword', + 'PSAvoidUsingUsernameAndPasswordParams', + 'PSUsePSCredentialType', + 'PSAvoidUsingConvertToSecureStringWithPlainText', + 'PSAvoidUsingInvokeExpression', + 'PSAvoidUsingComputerNameHardcoded' + ) + } + 'CI' { + $splat.ExcludeRule = @( + 'PSAvoidUsingWriteHost', + 'PSAvoidUsingWMICmdlet', + 'PSUseShouldProcessForStateChangingFunctions', + 'PSUseBOMForUnicodeEncodedFile', + 'PSReviewUnusedParameter', + 'PSUseSingularNouns' + ) + } + } + if ($Fix) { $splat.Fix = $true } + $results = Invoke-ScriptAnalyzer @splat + if (-not $results) { Write-Host 'No issues found.' -ForegroundColor Green; return } + $byRule = $results | Group-Object RuleName | Sort-Object Count -Descending + Write-Host '' + foreach ($g in $byRule) { + Write-Host ("[{0}] {1}" -f $g.Count, $g.Name) -ForegroundColor Cyan + foreach ($r in $g.Group) { + $color = switch ($r.Severity) { 'Error' { 'Red' } 'Warning' { 'Yellow' } default { 'DarkGray' } } + $rel = try { Resolve-Path -LiteralPath $r.ScriptPath -Relative } catch { $r.ScriptPath } + Write-Host (" {0,-40} L{1,-5} {2}" -f $rel, $r.Line, $r.Message) -ForegroundColor $color + } + } + Write-Host '' + Write-Host ("Total: {0}" -f $results.Count) -ForegroundColor Magenta +} + +# AST walker that finds unused parameters and top-level functions with no call sites in the same file. +function Find-DeadCode { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "File not found: $Path"; return } + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($resolved.Path, [ref]$tokens, [ref]$parseErrors) + $fns = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) + + Write-Host 'Unused parameters:' -ForegroundColor Cyan + $anyUnused = $false + foreach ($f in $fns) { + $params = @() + if ($f.Body -and $f.Body.ParamBlock) { $params = $f.Body.ParamBlock.Parameters } + foreach ($p in $params) { + $name = $p.Name.VariablePath.UserPath + $refs = $f.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.VariableExpressionAst] -and + $n.VariablePath.UserPath -eq $name -and + $n.Extent.StartOffset -ne $p.Name.Extent.StartOffset + }, $true) + if (-not $refs -or $refs.Count -eq 0) { + Write-Host (" {0} L{1}: `${2}" -f $f.Name, $p.Extent.StartLineNumber, $name) -ForegroundColor Yellow + $anyUnused = $true + } + } + } + if (-not $anyUnused) { Write-Host ' (none)' -ForegroundColor DarkGray } + + Write-Host '' + Write-Host 'Possibly uncalled functions (same-file check):' -ForegroundColor Cyan + $calls = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) | + ForEach-Object { $_.CommandElements[0].Extent.Text } + $anyUncalled = $false + foreach ($f in $fns) { + if ($calls -notcontains $f.Name) { + Write-Host (" {0} (L{1})" -f $f.Name, $f.Extent.StartLineNumber) -ForegroundColor Yellow + $anyUncalled = $true + } + } + if (-not $anyUncalled) { Write-Host ' (none)' -ForegroundColor DarkGray } +} + +# Profile self-diagnostics: version, policy, caches, tools, environment flags. +function Test-Profile { + Write-Host 'Profile Diagnostics' -ForegroundColor Cyan + Write-Host '' + Write-Host (" PS Version: {0} ({1})" -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition) + Write-Host (" Language Mode: {0}" -f $ExecutionContext.SessionState.LanguageMode) + Write-Host (" Execution Policy: {0}" -f (Get-ExecutionPolicy)) + Write-Host (" Profile Path: {0}" -f $PROFILE) + Write-Host (" Profile Exists: {0}" -f (Test-Path $PROFILE)) + $userOverride = Join-Path (Split-Path $PROFILE) 'profile_user.ps1' + Write-Host (" profile_user.ps1: {0}" -f $(if (Test-Path $userOverride) { 'present' } else { 'absent' })) + $cache = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' + Write-Host (" Cache Dir: {0}" -f $cache) + if (Test-Path $cache) { + $cacheFiles = Get-ChildItem $cache -File -ErrorAction SilentlyContinue + Write-Host (" Cache Files: {0}" -f $cacheFiles.Count) + foreach ($cf in $cacheFiles) { + $sz = if ($cf.Length -gt 1024) { '{0} KB' -f [math]::Round($cf.Length / 1KB, 1) } else { '{0} B' -f $cf.Length } + Write-Host (" {0,-28} {1}" -f $cf.Name, $sz) -ForegroundColor DarkGray + } + } + Write-Host (" Modules Loaded: {0}" -f (Get-Module).Count) + Write-Host '' + Write-Host 'Managed Tools:' -ForegroundColor Cyan + foreach ($tool in $script:ProfileTools) { + $found = Get-Command $tool.Cmd -ErrorAction SilentlyContinue + $status = if ($found) { 'OK' } else { 'MISSING' } + $color = if ($found) { 'Green' } else { 'Yellow' } + Write-Host (" {0,-14} {1}" -f $tool.Cmd, $status) -ForegroundColor $color + } + Write-Host '' + Write-Host 'Environment:' -ForegroundColor Cyan + Write-Host (" Interactive: {0}" -f $isInteractive) + Write-Host (" Admin: {0}" -f $isAdmin) + Write-Host (" CI: {0}" -f [bool]$env:CI) + Write-Host (" AI_AGENT: {0}" -f [bool]$env:AI_AGENT) +} + +# Enumerate every installed PowerShell (5.1 Desktop + all Core/7+ locations). +function Get-PwshVersions { + $found = [System.Collections.Generic.List[object]]::new() + $ps5 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (Test-Path $ps5) { + try { + $ver = (& $ps5 -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>$null) + if ($ver) { $found.Add([PSCustomObject]@{ Edition = 'Desktop'; Version = $ver; Path = $ps5 }) } + } + catch { $null = $_ } + } + $candidates = @() + $candidates += (Get-Command pwsh -ErrorAction SilentlyContinue -All | ForEach-Object { $_.Source }) + $candidates += (Get-ChildItem 'C:\Program Files\PowerShell' -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName 'pwsh.exe' }) + $candidates += (Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WindowsApps" -Filter 'pwsh*.exe' -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) + $candidates = $candidates | Where-Object { $_ -and (Test-Path $_) } | Sort-Object -Unique + foreach ($path in $candidates) { + try { + $ver = & $path -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>$null + if ($ver) { $found.Add([PSCustomObject]@{ Edition = 'Core'; Version = $ver; Path = $path }) } + } + catch { $null = $_ } + } + if ($found.Count -eq 0) { Write-Warning 'No PowerShell installations found.'; return } + $found | Sort-Object Edition, Version | Format-Table Edition, Version, Path -AutoSize +} + +# Inspect an installed module: all versions, path, exports, signature. +# Emits one PSCustomObject per installed version so callers can filter/select. +function modinfo { + param([Parameter(Mandatory)][string]$Name) + $installed = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | Sort-Object Version -Descending + if (-not $installed) { Write-Warning "Module not found: $Name"; return } + foreach ($m in $installed) { + $signed = $null + $signer = $null + if ($m.Path) { + $sig = Get-AuthenticodeSignature -LiteralPath $m.Path -ErrorAction SilentlyContinue + if ($sig) { + $signed = [string]$sig.Status + if ($sig.SignerCertificate) { $signer = $sig.SignerCertificate.Subject } + } + } + [PSCustomObject]@{ + Name = $m.Name + Version = $m.Version + ModuleType = $m.ModuleType + Path = $m.Path + Author = $m.Author + Description = $m.Description + Requires = if ($m.RequiredModules) { ($m.RequiredModules | ForEach-Object { $_.Name }) -join ', ' } else { '' } + Functions = $m.ExportedFunctions.Count + Cmdlets = $m.ExportedCmdlets.Count + Aliases = $m.ExportedAliases.Count + Signed = $signed + Signer = $signer + } + } +} + +# AST-based code-pattern search (grep but structural). -Kind narrows to one AST node type. +function psgrep { + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)][string]$Pattern, + [Parameter(Position = 1)][string]$Root = '.', + [ValidateSet('Command', 'Variable', 'String', 'Function', 'Any')][string]$Kind = 'Any' + ) + $resolved = Resolve-Path -LiteralPath $Root -ErrorAction SilentlyContinue + if (-not $resolved) { Write-Error "Root not found: $Root"; return } + $predicate = switch ($Kind) { + 'Command' { { param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.CommandElements[0].Extent.Text -match $Pattern } } + 'Variable' { { param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] -and $n.VariablePath.UserPath -match $Pattern } } + 'String' { { param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $n.Value -match $Pattern } } + 'Function' { { param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -match $Pattern } } + 'Any' { { param($n) $n.Extent.Text -match $Pattern } } + } + Get-ChildItem -Path $resolved -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + $file = $_.FullName + $tokens = $null; $parseErrors = $null + $ast = $null + try { $ast = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$parseErrors) } + catch { return } + if (-not $ast) { return } + $hits = $ast.FindAll($predicate, $true) + foreach ($h in $hits) { + $snippet = ($h.Extent.Text -split "`r?`n")[0].Trim() + if ($snippet.Length -gt 120) { $snippet = $snippet.Substring(0, 120) + '...' } + Write-Host ("{0}:{1}: {2}" -f $file, $h.Extent.StartLineNumber, $snippet) + } + } +} + +function Test-ProfileHistorySafeLine { + param([AllowNull()][string]$Line) + if ([string]::IsNullOrWhiteSpace($Line)) { return $true } + $sensitivePatterns = @( + '(?i)password' + '(?i)secret' + '(?i)token' + '(?i)api[_-]?key' + '(?i)connectionstring' + '(?i)credential' + '(?i)bearer' + '(?i)\b(VTCLI_APIKEY|VT_API_KEY)\b' + # Match the verb as a standalone token with an argument anywhere on the line (\b ... \s), so every + # chained/piped/assigned/braced form is scrubbed: `$p = pwnd secret`, `cls; pwnd secret`, + # `x | jwtd tok`, `1..3 | % { pwnd secret }`. \b keeps benign mentions like `pwnd-notes.txt` (no + # trailing space) and `mypwnd` from matching. Passwords have no fixed shape, so this is the only + # backstop for pwnd; jwtd additionally has the eyJ... token-shape catch below. Over-scrubbing a benign + # line is harmless; failing to scrub a secret is not - so this errs toward matching. + '(?i)\bpwnd\s' + '(?i)\bjwtd\s' + '\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\b' + # Value-shaped secrets: catch the actual credential even when no keyword is present (the keyword + # patterns above miss e.g. `git remote add o https://ghp_xxx@github.com` or `mysql -pHunter2024`). + '\bghp_[A-Za-z0-9]{20,}' # GitHub personal access token + '\bgh[ousr]_[A-Za-z0-9]{20,}' # GitHub oauth/user/server/refresh tokens + '\bgithub_pat_[A-Za-z0-9_]{20,}' # GitHub fine-grained PAT + '\bAKIA[0-9A-Z]{16}\b' # AWS access key id + '\bASIA[0-9A-Z]{16}\b' # AWS temporary access key id + '(?i)\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack token + '\bAIza[0-9A-Za-z_\-]{35}\b' # Google API key + '\bsk-[A-Za-z0-9]{20,}' # OpenAI-style secret key + '-----BEGIN[A-Z ]*PRIVATE KEY-----' # PEM private key material + '(?i)://[^/\s:@]+:[^/\s:@]+@' # credentials embedded in a URL (user:pass@host) + '(?i)--password[=\s]\S' # explicit --password flag with a value + '(?i)\b(?:mysql|mysqldump|mariadb|psql)\b.*\s-p\S' # db client password attached to -p (not mkdir -p) + ) + foreach ($pattern in $sensitivePatterns) { + if ($Line -match $pattern) { return $false } + } + return $true +} + +# Apply user-settings.json feature toggles onto $script:PSP.Features. Single source of truth so the +# load-time consumers below (predictions/transientPrompt/psfzf) and the authoritative end-of-profile pass +# stay in sync. String "false"/"0"/"no"/"off"/"" -> $false (bare [bool] would coerce any non-empty string +# to $true); only keys that already exist in the defaults are honored. +function Set-PspFeatureOverride { + param($Settings) + if (-not $Settings -or -not $Settings.PSObject.Properties['features']) { return } + foreach ($prop in $Settings.features.PSObject.Properties) { + if ($script:PSP.Features.ContainsKey($prop.Name)) { + $val = $prop.Value + if ($val -is [string]) { $script:PSP.Features[$prop.Name] = ($val -notmatch '^(?i:false|0|no|off|)$') } + else { $script:PSP.Features[$prop.Name] = [bool]$val } + } + } +} + +# Enhanced PSReadLine Configuration. Colors read from theme.json (shipped palette) and +# then overridden from user-settings.json (wizard / manual overrides). EditMode/BellStyle +# remain here as behavior defaults (users override via profile_user.ps1 or Set-PSReadLineOption). +$_readlineColors = $null +try { + $_cachedTheme = Join-Path $cacheDir 'theme.json' + if (Test-Path $_cachedTheme) { + $_themeConfig = Get-Content $_cachedTheme -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($_themeConfig.psreadline -and $_themeConfig.psreadline.colors) { + $_readlineColors = @{} + foreach ($prop in $_themeConfig.psreadline.colors.PSObject.Properties) { + $_readlineColors[$prop.Name] = $prop.Value + } + } + } + # Merge user-settings.json.psreadline.colors on top so wizard/manual overrides win. + $_userSettingsForRL = Join-Path $cacheDir 'user-settings.json' + if (Test-Path $_userSettingsForRL) { + $_rlUser = Get-Utf8FileText $_userSettingsForRL | ConvertFrom-Json -ErrorAction Stop + # Apply feature toggles here, BEFORE the predictions/transientPrompt/psfzf consumers below. The + # authoritative override near end-of-profile runs ~1000 lines too late to affect these load-time features. + Set-PspFeatureOverride $_rlUser + if ($_rlUser.psreadline -and $_rlUser.psreadline.colors) { + if ($null -eq $_readlineColors) { $_readlineColors = @{} } + foreach ($prop in $_rlUser.psreadline.colors.PSObject.Properties) { + $_readlineColors[$prop.Name] = $prop.Value + } + } + } +} +catch { $null = $_ } +$PSReadLineOptions = @{ + EditMode = 'Windows' + HistoryNoDuplicates = $true + HistorySearchCursorMovesToEnd = $true + BellStyle = 'None' +} +if ($_readlineColors) { $PSReadLineOptions.Colors = $_readlineColors } +# A malformed color value in user-settings.json (e.g. a bad ANSI/hex string) makes Set-PSReadLineOption throw; +# without this guard that would abort profile load and break the prompt. Retry once without the user colors so +# the shell still comes up with working editing keys. +try { Set-PSReadLineOption @PSReadLineOptions } +catch { + Write-Warning "PSReadLine options rejected ($($_.Exception.Message)); applying without custom colors." + $PSReadLineOptions.Remove('Colors') + try { Set-PSReadLineOption @PSReadLineOptions } catch { Write-Warning "PSReadLine base options failed: $($_.Exception.Message)" } +} + +# PSReadLine features that require an interactive console host +if ($isInteractive -and (Get-Module PSReadLine)) { + # Core-only prediction settings (PredictionSource/PredictionViewStyle don't exist on Desktop) + # Guard against hosts without VT support (e.g. agent terminals, redirected output) + # Disable via user-settings.json: { "features": { "predictions": false } } + if ($PSVersionTable.PSEdition -eq "Core" -and $script:PSP.Features.predictions) { + $supportsPrediction = $false + try { + $supportsPrediction = [bool]$Host.UI.SupportsVirtualTerminal -and -not [Console]::IsOutputRedirected } catch { $supportsPrediction = $false @@ -3121,8 +5571,34 @@ if ($isInteractive -and (Get-Module PSReadLine)) { } Set-PSReadLineKeyHandler -Chord 'Alt+v' -BriefDescription SmartPaste -Description 'Paste clipboard as one block into prompt' -ScriptBlock $smartPasteHandler + # Transient prompt: on Enter, redraw the current prompt using a minimal scriptblock so + # the scrollback shows just the collapsed form. The full prompt still renders for the + # NEW line after AcceptLine. Opt-in via features.transientPrompt in user-settings.json. + # Customize via `$script:PSP.TransientPrompt = { ... }` in profile_user.ps1. + if ($script:PSP.Features.transientPrompt) { + Set-PSReadLineKeyHandler -Key Enter -BriefDescription TransientPrompt -Description 'Collapse the prior prompt to a minimal form before accepting' -ScriptBlock { + $parseErrors = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$null, [ref]$null, [ref]$parseErrors, [ref]$null) + if ($parseErrors.Count -eq 0) { + $originalPrompt = $Function:prompt + try { + $Function:prompt = { + if ($script:PSP -and $script:PSP.TransientPrompt) { + try { & $script:PSP.TransientPrompt } catch { '$ ' } + } + else { '$ ' } + } + [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() + } + finally { $Function:prompt = $originalPrompt } + } + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + } + } + # fzf integration via PSFzf (fuzzy history search on Ctrl+R, file finder on Ctrl+T) - if (Get-Command fzf -ErrorAction SilentlyContinue) { + # Disable via user-settings.json: { "features": { "psfzf": false } } + if ($script:PSP.Features.psfzf -and (Get-Command fzf -ErrorAction SilentlyContinue)) { if (-not $env:FZF_DEFAULT_COMMAND -and (Get-Command rg -ErrorAction SilentlyContinue)) { $env:FZF_DEFAULT_COMMAND = 'rg --files --hidden --glob "!.git"' } @@ -3140,9 +5616,73 @@ if ($isInteractive -and (Get-Module PSReadLine)) { # Filter sensitive commands from history Set-PSReadLineOption -AddToHistoryHandler { param($line) - $sensitive = @('password', 'secret', 'token', 'api[_-]?key', 'connectionstring', 'credential', 'bearer') - $hasSensitive = $sensitive | Where-Object { $line -match $_ } - return ($null -eq $hasSensitive) + return (Test-ProfileHistorySafeLine -Line $line) + } + + # Native tool completers. Each tool emits its own PowerShell completion script; we cache + # it to disk so shell start does not launch a subprocess per tool. Cache is cleared by + # Update-Profile (tool upgrades) and Clear-ProfileCache. + $_nativeCompleters = @( + @{ Cmd = 'kubectl'; Cache = 'kubectl-completion.ps1'; Args = @('completion', 'powershell') } + @{ Cmd = 'gh'; Cache = 'gh-completion.ps1'; Args = @('completion', '-s', 'powershell') } + @{ Cmd = 'docker'; Cache = 'docker-completion.ps1'; Args = @('completion', 'powershell') } + ) + foreach ($_nc in $_nativeCompleters) { + if (-not (Get-Command $_nc.Cmd -ErrorAction SilentlyContinue)) { continue } + $_ncCachePath = Join-Path $cacheDir $_nc.Cache + $_ncReady = (Test-Path $_ncCachePath) -and ((Get-Item $_ncCachePath -ErrorAction SilentlyContinue).Length -gt 0) + if (-not $_ncReady) { + try { + $_ncOutput = & $_nc.Cmd @($_nc.Args) 2>$null | Out-String + if ($_ncOutput -and $_ncOutput.Trim().Length -gt 0) { + [System.IO.File]::WriteAllText($_ncCachePath, $_ncOutput, [System.Text.UTF8Encoding]::new($false)) + $_ncReady = $true + } + } + catch { $null = $_ } + } + if ($_ncReady) { + try { . $_ncCachePath } + catch { + # Corrupt cache; delete so next load regenerates. + Remove-Item -LiteralPath $_ncCachePath -Force -ErrorAction SilentlyContinue + } + } + } + + # Tab-title context indicators (starship / p10k style): prepend venv/conda/AWS/K8s/jobs + # to the base window title on every prompt. Kubernetes context is read directly from + # ~/.kube/config (file read, no subprocess). Jobs-count only appears when > 0. + $script:PSP.BaseTitle = "PowerShell {0}{1}" -f $PSVersionTable.PSVersion, $adminSuffix + Register-ProfileHook -EventName PrePrompt -Action { + try { + $parts = @() + if ($env:VIRTUAL_ENV) { $parts += "venv:$(Split-Path $env:VIRTUAL_ENV -Leaf)" } + if ($env:CONDA_DEFAULT_ENV) { $parts += "conda:$env:CONDA_DEFAULT_ENV" } + if ($env:AWS_PROFILE) { $parts += "aws:$env:AWS_PROFILE" } + if ($env:AWS_VAULT) { $parts += "aws-vault:$env:AWS_VAULT" } + # Kubernetes context via direct file read - zero subprocess overhead per prompt. + $kubeConfig = Join-Path $HOME '.kube/config' + if (Test-Path -LiteralPath $kubeConfig) { + try { + $match = Select-String -Path $kubeConfig -Pattern '^current-context:\s*(.+)$' -ErrorAction Stop | Select-Object -First 1 + if ($match) { + # YAML may quote the context value (current-context: "foo" or 'foo'); + # strip surrounding quotes so the tab title shows `foo` not `"foo"`. + $kubeCtx = $match.Matches[0].Groups[1].Value.Trim().Trim('"').Trim("'") + if ($kubeCtx) { $parts += "k8s:$kubeCtx" } + } + } + catch { $null = $_ } + } + $jobCount = @(Get-Job -State Running -ErrorAction SilentlyContinue).Count + if ($jobCount -gt 0) { $parts += "jobs:$jobCount" } + + $prefix = if ($parts.Count -gt 0) { '[' + ($parts -join ' ') + '] ' } else { '' } + $base = if ($script:PSP.BaseTitle) { $script:PSP.BaseTitle } else { '' } + try { $Host.UI.RawUI.WindowTitle = $prefix + $base } catch { $null = $_ } + } + catch { $null = $_ } } } @@ -3261,6 +5801,7 @@ if ($isInteractive) { $Function:prompt = { $originalSuccess = $? $originalLastExitCode = $global:LASTEXITCODE + Invoke-PromptStage try { $output = Get-OhMyPoshPromptText ` -Type 'primary' ` @@ -3376,6 +5917,320 @@ if ($isInteractive) { } } +# Query the profile command registry. Core commands are seeded at load time; plugins may +# add more via Register-ProfileCommand. Filter by category substring or name substring. +function Get-ProfileCommand { + [CmdletBinding()] + param( + [string]$Category, + [string]$Name + ) + $cmds = @($script:PSP.Commands) + if ($Category) { $cmds = $cmds | Where-Object { $_.Category -like "*$Category*" } } + if ($Name) { $cmds = $cmds | Where-Object { $_.Name -like "*$Name*" } } + $cmds | Sort-Object Category, Name +} + +# First-run walkthrough. Shows each category plus a handful of commands and pauses between sections. +function Start-ProfileTour { + if (-not [Environment]::UserInteractive) { Write-Warning 'Tour requires an interactive session.'; return } + $categories = @($script:PSP.Commands | Group-Object Category | Sort-Object Name) + if ($categories.Count -eq 0) { Write-Warning 'Command registry is empty. Is the profile loaded?'; return } + $oldTitle = Push-TabTitle 'profile tour' + try { + Write-Host '' + Write-Host 'PowerShellPerfect Tour' -ForegroundColor Cyan + Write-Host '======================' -ForegroundColor Cyan + Write-Host 'Press Enter to see each category, Ctrl+C to quit.' -ForegroundColor DarkGray + Write-Host '' + $null = Read-Host 'Ready' + foreach ($cat in $categories) { + Write-Host '' + Write-Host ("-- {0} ({1} commands) --" -f $cat.Name, $cat.Count) -ForegroundColor Cyan + foreach ($entry in ($cat.Group | Sort-Object Name | Select-Object -First 8)) { + $synopsis = if ($entry.Synopsis) { $entry.Synopsis } else { '' } + Write-Host (" {0,-22} {1}" -f $entry.Name, $synopsis) -ForegroundColor Gray + } + if ($cat.Group.Count -gt 8) { + Write-Host (" ...{0} more (Get-ProfileCommand -Category '{1}')" -f ($cat.Group.Count - 8), $cat.Name) -ForegroundColor DarkGray + } + $null = Read-Host 'Press Enter' + } + Write-Host '' + Write-Host 'Extend the profile:' -ForegroundColor Cyan + Write-Host ' profile_user.ps1 - PS overrides (after user-settings.json, before plugins)' + Write-Host ' plugins\*.ps1 - drop files in %LOCALAPPDATA%\PowerShellProfile\plugins' + Write-Host ' user-settings.json - features toggles, commandOverrides, trustedDirs' + Write-Host ' .psprc.ps1 - per-directory profile (opt-in via Add-TrustedDirectory)' + Write-Host '' + Write-Host 'Tour complete.' -ForegroundColor Green + } + finally { Pop-TabTitle $oldTitle } +} + +# Trust the given directory so its .psprc.ps1 auto-loads on cd. Default: current directory. +# Persists to user-settings.json so the trust survives profile reloads. +function Add-TrustedDirectory { + [CmdletBinding(SupportsShouldProcess = $true)] + param([string]$Path) + if (-not $Path) { $Path = (Get-Location).ProviderPath } + # Resolve-Path must succeed: a raw (unresolved) string in TrustedDirs would never equal + # $PWD.ProviderPath later, silently breaking the trust check on cd into that directory. + $resolved = try { (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath } + catch { + Write-Error "Cannot trust '$Path': path does not exist or is not resolvable." + return + } + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + Write-Error "Cannot trust '$resolved': not a directory." + return + } + if ($script:PSP.TrustedDirs -contains $resolved) { + Write-Host "Already trusted: $resolved" -ForegroundColor DarkGray + return + } + if (-not $PSCmdlet.ShouldProcess($resolved, 'Trust for .psprc.ps1 auto-load')) { return } + $script:PSP.TrustedDirs.Add($resolved) + # Belt-and-suspenders: even though Save-TrustedDirectories is supposed to return $false on + # failure, wrap in try/catch so any unexpected throw still triggers rollback and we never + # report "Trusted:" when disk state doesn't match memory. + $saved = $false + try { $saved = Save-TrustedDirectories } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning "Save failed: $($_.Exception.Message)" + } + if (-not $saved) { + [void]$script:PSP.TrustedDirs.Remove($resolved) + Write-Warning "Trust was not persisted. Fix user-settings.json and retry." + return + } + Write-Host "Trusted: $resolved" -ForegroundColor Green + if (Test-Path -LiteralPath (Join-Path $resolved '.psprc.ps1')) { + Write-Host 'Reloading .psprc.ps1 now...' -ForegroundColor DarkGray + try { . (Join-Path $resolved '.psprc.ps1') } + catch { Write-Warning ".psprc.ps1 failed: $($_.Exception.Message)" } + } +} + +# Remove a directory from the trust list. Default: current directory. +# Accepts stale/deleted paths so users can clean up entries for directories that no longer +# exist. Matching is case-insensitive to mirror Windows filesystem semantics. +function Remove-TrustedDirectory { + [CmdletBinding(SupportsShouldProcess = $true)] + param([string]$Path) + if (-not $Path) { $Path = (Get-Location).ProviderPath } + $resolved = try { (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath } catch { $Path } + # Case-insensitive match so Windows path casing differences don't hide the entry. + $match = $script:PSP.TrustedDirs | Where-Object { $_ -ieq $resolved } | Select-Object -First 1 + if (-not $match) { + Write-Host "Not trusted: $resolved" -ForegroundColor DarkGray + return + } + $resolved = $match + if (-not $PSCmdlet.ShouldProcess($resolved, 'Remove from trusted directories')) { return } + [void]$script:PSP.TrustedDirs.Remove($resolved) + $saved = $false + try { $saved = Save-TrustedDirectories } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning "Save failed: $($_.Exception.Message)" + } + if (-not $saved) { + [void]$script:PSP.TrustedDirs.Add($resolved) + Write-Warning "Untrust was not persisted. Fix user-settings.json and retry." + return + } + Write-Host "Untrusted: $resolved" -ForegroundColor Yellow +} + +# Set or clear the Windows Terminal background image. +# Persists to user-settings.json (so Update-Profile re-applies it) and writes the change +# live to WT settings.json so open windows reload without a restart. +# Pass -Clear (or omit -Path) to remove the background. WT supports jpg/png/gif/tif/bmp. +function Set-TerminalBackground { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Position = 0)][string]$Path, + [ValidateRange(0.0, 1.0)][double]$Opacity = 0.3, + [ValidateSet('none', 'fill', 'uniform', 'uniformToFill')][string]$StretchMode = 'uniformToFill', + [ValidateSet('center', 'left', 'top', 'right', 'bottom', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight')][string]$Alignment = 'center', + # When set, resize the image to this pixel width (preserving aspect ratio) before + # applying. Windows Terminal has no native size knob, so resizing the source file + # is the only way to get a smaller watermark. Resized copy is cached in the profile + # cache dir and re-used if you pass the same width again. + [ValidateRange(32, 4096)][int]$ResizeWidth, + [switch]$Clear + ) + $bgProps = @('backgroundImage', 'backgroundImageOpacity', 'backgroundImageStretchMode', 'backgroundImageAlignment') + if (-not $Clear -and -not $Path) { Write-Error 'Usage: Set-TerminalBackground | Set-TerminalBackground -Clear'; return } + $resolved = $null + if (-not $Clear) { + try { $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath } + catch { Write-Error "Image not found: $Path"; return } + if ($resolved -notmatch '\.(jpg|jpeg|png|gif|tif|tiff|bmp)$') { + Write-Warning 'Unusual image extension. Windows Terminal supports jpg/png/gif/tif/bmp.' + } + # Resize step. Writes resized PNG into $cacheDir\bg--.png so the + # same input+width combination re-uses a cached copy and the next call is instant. + if ($ResizeWidth) { + try { + Add-Type -AssemblyName System.Drawing -ErrorAction Stop + $srcName = [System.IO.Path]::GetFileNameWithoutExtension($resolved) + $resizedPath = Join-Path $cacheDir ("bg-{0}-{1}.png" -f $srcName, $ResizeWidth) + $regen = $true + if (Test-Path -LiteralPath $resizedPath) { + $srcTime = (Get-Item -LiteralPath $resolved).LastWriteTimeUtc + $cachedTime = (Get-Item -LiteralPath $resizedPath).LastWriteTimeUtc + if ($cachedTime -ge $srcTime) { $regen = $false } + } + if ($regen) { + $img = [System.Drawing.Image]::FromFile($resolved) + try { + $h = [int]($img.Height * ($ResizeWidth / $img.Width)) + $bmp = [System.Drawing.Bitmap]::new($ResizeWidth, $h) + $g = [System.Drawing.Graphics]::FromImage($bmp) + try { + $g.InterpolationMode = 'HighQualityBicubic' + $g.DrawImage($img, 0, 0, $ResizeWidth, $h) + $bmp.Save($resizedPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { $g.Dispose(); $bmp.Dispose() } + } + finally { $img.Dispose() } + Write-Host ("Resized to {0}px wide: {1}" -f $ResizeWidth, $resizedPath) -ForegroundColor DarkGray + } + $resolved = $resizedPath + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning "Resize failed (using original image): $($_.Exception.Message)" + } + } + } + + # Only overwrite the appearance knobs the caller actually passed. An image-only call + # (`Set-TerminalBackground new.png`) must NOT silently reset a previously-chosen opacity/stretch/alignment + # back to defaults; first-time use still gets the defaults because the property won't exist yet. Shared + # apply-block runs against both the user-settings and the WT defaults objects so they stay consistent. + $setOpacity = $PSBoundParameters.ContainsKey('Opacity') + $setStretch = $PSBoundParameters.ContainsKey('StretchMode') + $setAlignment = $PSBoundParameters.ContainsKey('Alignment') + $applyBg = { + param($defaultsObj) + $defaultsObj | Add-Member -NotePropertyName 'backgroundImage' -NotePropertyValue $resolved -Force + if ($setOpacity -or -not $defaultsObj.PSObject.Properties['backgroundImageOpacity']) { $defaultsObj | Add-Member -NotePropertyName 'backgroundImageOpacity' -NotePropertyValue $Opacity -Force } + if ($setStretch -or -not $defaultsObj.PSObject.Properties['backgroundImageStretchMode']) { $defaultsObj | Add-Member -NotePropertyName 'backgroundImageStretchMode' -NotePropertyValue $StretchMode -Force } + if ($setAlignment -or -not $defaultsObj.PSObject.Properties['backgroundImageAlignment']) { $defaultsObj | Add-Member -NotePropertyName 'backgroundImageAlignment' -NotePropertyValue $Alignment -Force } + } + + # 1. Persist to user-settings.json under defaults.* + $settingsPath = Join-Path $cacheDir 'user-settings.json' + try { + $settings = Read-UserSettingsForWrite -Path $settingsPath + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "Could not read user-settings.json: $($_.Exception.Message). Background not persisted." + return + } + if (-not $settings.PSObject.Properties['defaults']) { + $settings | Add-Member -NotePropertyName 'defaults' -NotePropertyValue ([PSCustomObject]@{}) -Force + } + if ($Clear) { + foreach ($p in $bgProps) { + if ($settings.defaults.PSObject.Properties[$p]) { $settings.defaults.PSObject.Properties.Remove($p) } + } + } + else { + & $applyBg $settings.defaults + } + if ($PSCmdlet.ShouldProcess($settingsPath, 'Persist terminal background in user-settings.json')) { + $json = $settings | ConvertTo-Json -Depth 10 + Write-Utf8FileAtomic $settingsPath $json + } + + # 2. Apply live to WT settings.json so change is visible immediately. Iterate ALL installed + # WT variants (Stable / Preview / Canary / unpackaged) so multi-variant users get a + # consistent background everywhere, not just whichever comes first in precedence order. + $wtSettingsPaths = Get-WindowsTerminalSettingsPaths + if (-not $wtSettingsPaths -or $wtSettingsPaths.Count -eq 0) { + Write-Host 'Windows Terminal settings.json not found (Store/Preview/Canary/unpackaged). Change persisted; will apply after next Update-Profile.' -ForegroundColor DarkGray + return + } + foreach ($wtSettingsPath in $wtSettingsPaths) { + try { + $wtRaw = (Get-Utf8FileText $wtSettingsPath) -replace $jsoncCommentPattern, '' + $wt = $wtRaw | ConvertFrom-Json + if (-not $wt.profiles) { $wt | Add-Member -NotePropertyName 'profiles' -NotePropertyValue ([PSCustomObject]@{}) -Force } + if (-not $wt.profiles.defaults) { $wt.profiles | Add-Member -NotePropertyName 'defaults' -NotePropertyValue ([PSCustomObject]@{}) -Force } + if ($Clear) { + foreach ($p in $bgProps) { + if ($wt.profiles.defaults.PSObject.Properties[$p]) { $wt.profiles.defaults.PSObject.Properties.Remove($p) } + } + } + else { + & $applyBg $wt.profiles.defaults + } + if ($PSCmdlet.ShouldProcess($wtSettingsPath, 'Apply terminal background live')) { + $wtJson = $wt | ConvertTo-Json -Depth 100 + Write-Utf8FileAtomic $wtSettingsPath $wtJson + } + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning "Live apply to WT settings.json failed for $wtSettingsPath`: $($_.Exception.Message)" + } + } + if ($Clear) { Write-Host 'Terminal background cleared.' -ForegroundColor Yellow } + else { Write-Host ("Terminal background: {0} (opacity {1}, {2}, {3})" -f $resolved, $Opacity, $StretchMode, $Alignment) -ForegroundColor Green } +} + +# Internal: read user-settings.json into a PSCustomObject. Throws on unreadable/invalid JSON +# so callers MUST wrap in try/catch (throw is used rather than Write-Error so the behavior +# is identical regardless of the caller's $ErrorActionPreference). Returns an empty +# PSCustomObject when the file does not exist (first run). +function Read-UserSettingsForWrite { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return [PSCustomObject]@{} } + $raw = Get-Utf8FileText $Path + if ([string]::IsNullOrWhiteSpace($raw)) { return [PSCustomObject]@{} } + return $raw | ConvertFrom-Json -ErrorAction Stop +} + +# Internal: persist $script:PSP.TrustedDirs to user-settings.json under trustedDirs. +# Returns $true on success, $false if the file could not be read or written. Never throws +# (except PipelineStoppedException) so callers can rely on the bool for rollback decisions +# regardless of $ErrorActionPreference. +function Save-TrustedDirectories { + $settingsPath = Join-Path $cacheDir 'user-settings.json' + try { + $settings = Read-UserSettingsForWrite -Path $settingsPath + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "Could not read user-settings.json: $($_.Exception.Message). Trust changes not persisted." + return $false + } + $dirs = @($script:PSP.TrustedDirs) + if ($settings.PSObject.Properties['trustedDirs']) { + $settings.trustedDirs = $dirs + } + else { + $settings | Add-Member -NotePropertyName 'trustedDirs' -NotePropertyValue $dirs -Force + } + try { + $json = $settings | ConvertTo-Json -Depth 10 + Write-Utf8FileAtomic $settingsPath $json + return $true + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "Could not write user-settings.json: $($_.Exception.Message)" + return $false + } +} + # Help Function (PS5-compatible - $PSStyle only exists in PS7.2+) function Show-Help { if ($null -ne $PSStyle) { @@ -3399,6 +6254,8 @@ ${g}Show-Help${r} - Show this help message. ${g}reload${r} - Reload the PowerShell profile. ${g}Clear-ProfileCache${r} - Reset profile caches plus OMP internal caches. ${g}Clear-Cache${r} [-IncludeSystemCaches] - Clear user/system temp caches. +${g}duration${r} - Elapsed time of the last command. +${g}Test-ProfileHealth${r} / ${g}psp-doctor${r} - Diagnose install: tools, caches, fonts, PATH. ${g}Uninstall-Profile${r} - Remove profile, caches, and WT changes. Use -All for everything, -HardResetWindowsTerminal to reset WT to defaults. ${c}Git${r} @@ -3418,6 +6275,8 @@ ${g}extract${r} - Universal extractor (.zip, .tar, .gz, .7z, .rar). ${g}file${r} - Identify file type via magic bytes (like Linux file command). ${g}sizeof${r} - Human-readable file/directory size. ${g}docs${r} / ${g}dtop${r} - Jump to Documents / Desktop. +${g}cdb${r} [N] - cd back N entries in directory history (default 1, previous dir). +${g}cdh${r} - List the cd history stack (most-recent first). ${c}Unix-like${r} ${g}grep${r} [dir] - Search for pattern in files (uses ripgrep when available). @@ -3446,8 +6305,8 @@ ${g}weather${r} [city] - Quick weather lookup. ${g}speedtest${r} - Download speed test. ${g}wifipass${r} [ssid] - Show saved WiFi passwords. ${g}hosts${r} - Open hosts file in elevated editor. -${g}winutil${r} - Launch Chris Titus WinUtil. -${g}harden${r} - Open Harden Windows Security. +${g}winutil${r} [-ExpectedSha256 ] [-Force] - Safe-by-default Chris Titus WinUtil fetch. Shows SHA256 + URL, then requires explicit confirmation before any execution. +${g}harden${r} - Open Harden Windows Security (prompts before launch). ${c}Security & Crypto${r} ${g}hash${r} [algo] - File hash (default SHA256). @@ -3458,11 +6317,12 @@ ${g}jwtd${r} - Decode JWT header and payload. ${g}uuid${r} - Generate random UUID (copies to clipboard). ${g}epoch${r} [value] - Unix timestamp converter (no args = now). ${g}urlencode${r} / ${g}urldecode${r} - URL encode / decode. -${g}vtscan${r} - Quick VirusTotal scan + open in browser. Uses ${g}`$env:VTCLI_APIKEY${r} or ${g}vt init${r}. +${g}vtscan${r} [-Upload] - VirusTotal hash lookup; ${g}-Upload${r} submits unknown files. ${g}vt${r} - Full VirusTotal CLI (vt-cli). Run ${g}vt --help${r} for details. ${c}Developer${r} ${g}killport${r} - Kill process on a TCP port. +${g}killports${r} / ${g}Stop-ListeningPort${r} - Interactive picker: lists all listening ports, pick one or many via fzf, kill. ${g}http${r} [-Method POST] [-Body '...'] - HTTP requests, auto-formats JSON. ${g}prettyjson${r} - Pretty-print JSON (or pipe: ${g}cat data.json | prettyjson${r}). ${g}hb${r} - Upload to hastebin, copy URL. @@ -3476,10 +6336,60 @@ ${g}dlogs${r} - Follow logs. ${g}dex${r} [shell] - Exec ${g}dstop${r} - Stop all. ${g}dprune${r} - System prune. ${c}SSH & Remote${r} (ssh/keygen when installed) +${g}ssh${r} - Wraps ssh.exe with ConnectTimeout=10 + keepalive so hangs respond to Ctrl+C. ${g}Copy-SshKey${r} / ${g}ssh-copy-key${r} - Copy SSH key to remote. ${g}keygen${r} [name] - Generate ED25519 key pair. ${g}rdp${r} - Launch RDP session. +${c}WSL${r} (when wsl.exe is installed) +${g}wsl${r} [args] - Wraps wsl.exe; sets tab title to distro name. +${g}Get-WslDistro${r} - List distros with state + version + default flag. +${g}Enter-WslHere${r} [-Distro] / ${g}wsl-here${r} - Open WSL shell in the current Windows directory. +${g}Get-WslFile${r} [path] [-Recurse] - List files in a distro via UNC (pipe-friendly). +${g}Show-WslTree${r} / ${g}wsl-tree${r} [path] [-Depth N] - Tree view (eza when available). +${g}Open-WslExplorer${r} / ${g}wsl-explorer${r} [path] - Open in Windows Explorer (GUI). +${g}ConvertTo-WslPath${r} / ${g}ConvertTo-WindowsPath${r} - Path translation. +${g}Get-WslIp${r} [-Distro] - IPv4 of a running distro (for connecting to services). +${g}Stop-Wsl${r} [-Distro] - Shutdown all distros, or terminate one by name. + +${c}Sysadmin${r} +${g}journal${r} [log] [-Count n] [-Follow] [-Level ...] - Tail Windows Event Log (journalctl-style). +${g}lsblk${r} - List disks and partitions with volume info. +${g}htop${r} - Interactive process viewer (uses btop/ntop/htop if installed, else svc -Live). +${g}mtr${r} - Traceroute with per-hop ping stats. +${g}fwallow${r} / ${g}fwblock${r} [-Port n] - Quick Windows Firewall rule (needs admin; supports -WhatIf/-Confirm). +${g}Find-FileLocker${r} - Show which processes hold a file/folder lock (uses Restart-Manager API, same as Explorer). +${g}Stop-StuckProcess${r} [-Tree] - Escalating kill for processes that ignore Stop-Process. +${g}Remove-LockedItem${r} [-Recurse] - Find lockers, kill them, then delete. For "file is in use" errors. + +${c}Cybersec${r} +${g}nscan${r} [-Mode Quick/Full/Services/Stealth/Vuln/Ports] - Curated nmap profiles. +${g}sigcheck${r} - Authenticode signature details (file or directory). +${g}ads${r} - List NTFS alternate data streams. +${g}defscan${r} [path] [-Mode Quick/Full] - Windows Defender scan wrapper. +${g}pwnd${r} - HIBP k-anonymity breach lookup (only first 5 SHA1 chars leave the host). +${g}certcheck${r} [port] - Full TLS probe: chain, SAN, SHA256 pin, cipher. +${g}entropy${r} - Shannon entropy (detect packed/encrypted payloads). + +${c}Developer+${r} +${g}serve${r} [port] [path] - One-line HTTP server (python or npx). +${g}gitignore${r} - Generate .gitignore from gitignore.io. +${g}gcof${r} - Fuzzy git branch checkout (fzf). +${g}envload${r} [path] - Load .env file into current session. +${g}tldr${r} - Quick command-example lookup (tldr-pages). +${g}repeat${r} { cmd } [-UntilSuccess] [-DelaySeconds n] - Repeat a scriptblock. +${g}mkvenv${r} [name] - Create and activate a Python venv. + +${c}Detection & AST${r} +${g}outline${r} - List functions/params/aliases via AST parser. +${g}psym${r} [pattern] [root] - Symbol search across .ps1 files. +${g}lint${r} [path] [-Mode Standard/Strict/Security/CI] [-Fix] - PSScriptAnalyzer wrapper. +${g}Find-DeadCode${r} - Unused params and same-file uncalled functions. +${g}Test-Profile${r} - Profile diagnostics: version, policy, caches, tools, env. +${g}Get-PwshVersions${r} - Enumerate every installed PowerShell. +${g}modinfo${r} - Module details: path, version(s), exports, signature. +${g}psgrep${r} [-Kind Command/Variable/String/Function] - AST-based code search. + ${c}Clipboard${r} ${g}cpy${r} - Copy to clipboard. ${g}pst${r} - Paste from clipboard. ${g}icb${r} - Insert clipboard into prompt (never executes). @@ -3488,21 +6398,320 @@ ${c}Keybindings${r} ${g}Ctrl+R${r} - Fuzzy history search (fzf). ${g}Ctrl+T${r} - Fuzzy file finder (fzf). ${g}Alt+V${r} - Smart paste into prompt. -Edit '${m}profile_user.ps1${r}' in your profile directory for customizations that survive updates. +${c}Extensibility${r} +${g}Get-ProfileCommand${r} [-Category ...] [-Name ...] - Query the command registry. +${g}Start-ProfileTour${r} - Interactive walkthrough of every category. +${g}Register-ProfileHook${r} -Event OnProfileLoad/PrePrompt/OnCd -Action { ... } - Hook lifecycle events. +${g}Register-HelpSection${r} -Title ... -Lines @(...) - Add a section to this help. +${g}Register-ProfileCommand${r} -Name ... -Category ... [-Synopsis ...] - Add to command registry. +${g}Add-TrustedDirectory${r} / ${g}Remove-TrustedDirectory${r} [path] - Trust a dir so .psprc.ps1 auto-loads. + +${c}Theme${r} +${g}Set-TerminalBackground${r} [-Opacity 0.3] [-StretchMode ...] [-Alignment ...] - Set WT background image (live + persisted). +${g}Set-TerminalBackground${r} -Clear - Remove the background image. + +Extend the profile without forking: + ${m}profile_user.ps1${r} - PS-level overrides (after user-settings.json, before plugins). + ${m}%LOCALAPPDATA%\PowerShellProfile\plugins\*.ps1${r} - drop-in plugins (auto-loaded). + ${m}user-settings.json${r} - features toggles, commandOverrides, trustedDirs. + ${m}.psprc.ps1${r} - per-directory profile (opt-in via Add-TrustedDirectory). + For lasting functions/aliases inside .psprc.ps1, use ${g}function global:foo${r} or ${g}Set-Alias -Scope Global${r}. + ${y}`$env:VAR = ...${r} always persists. Plain function/alias definitions are scoped and disappear after prompt render. "@ Write-Host $helpText + if ($script:PSP -and $script:PSP.HelpSections.Count -gt 0) { + foreach ($section in $script:PSP.HelpSections) { + Write-Host '' + Write-Host ("${c}$($section.Title)${r}") + foreach ($line in $section.Lines) { Write-Host $line } + } + } +} + +# Seed the command registry so Get-ProfileCommand and Start-ProfileTour return useful results. +# Data-only; plugins and profile_user.ps1 may append via Register-ProfileCommand. +$script:_seedCommands = @( + @{ Name = 'Update-Profile'; Category = 'Profile'; Synopsis = 'Sync profile, theme, caches, WT settings' } + @{ Name = 'Update-PowerShell'; Category = 'Profile'; Synopsis = 'Check for new PowerShell releases' } + @{ Name = 'Update-Tools'; Category = 'Profile'; Synopsis = 'Upgrade winget-managed tools' } + @{ Name = 'Edit-Profile'; Category = 'Profile'; Synopsis = 'Open profile in preferred editor' } + @{ Name = 'Show-Help'; Category = 'Profile'; Synopsis = 'Show this help message' } + @{ Name = 'reload'; Category = 'Profile'; Synopsis = 'Reload the profile in-place' } + @{ Name = 'Clear-ProfileCache'; Category = 'Profile'; Synopsis = 'Reset caches except user settings' } + @{ Name = 'Clear-Cache'; Category = 'Profile'; Synopsis = 'Clear user/system temp caches' } + @{ Name = 'Uninstall-Profile'; Category = 'Profile'; Synopsis = 'Remove profile, caches, WT changes' } + @{ Name = 'Invoke-ProfileWizard'; Category = 'Profile'; Synopsis = 'Re-run install wizard (alias: Reconfigure-Profile)' } + @{ Name = 'Test-Profile'; Category = 'Profile'; Synopsis = 'Profile diagnostics' } + @{ Name = 'gs'; Category = 'Git'; Synopsis = 'git status' } + @{ Name = 'ga'; Category = 'Git'; Synopsis = 'git add .' } + @{ Name = 'gc'; Category = 'Git'; Synopsis = 'git commit -m' } + @{ Name = 'gpush'; Category = 'Git'; Synopsis = 'git push' } + @{ Name = 'gpull'; Category = 'Git'; Synopsis = 'git pull' } + @{ Name = 'gcl'; Category = 'Git'; Synopsis = 'git clone' } + @{ Name = 'gcom'; Category = 'Git'; Synopsis = 'add + commit' } + @{ Name = 'lazyg'; Category = 'Git'; Synopsis = 'add + commit + push' } + @{ Name = 'g'; Category = 'Git'; Synopsis = 'zoxide jump to github dir' } + @{ Name = 'gcof'; Category = 'Git'; Synopsis = 'Fuzzy git branch checkout (fzf)' } + @{ Name = 'gitignore'; Category = 'Git'; Synopsis = 'Generate .gitignore from gitignore.io' } + @{ Name = 'ls'; Category = 'Files'; Synopsis = 'eza listing' } + @{ Name = 'la'; Category = 'Files'; Synopsis = 'eza listing with hidden' } + @{ Name = 'll'; Category = 'Files'; Synopsis = 'eza long + git listing' } + @{ Name = 'lt'; Category = 'Files'; Synopsis = 'eza tree listing' } + @{ Name = 'cat'; Category = 'Files'; Synopsis = 'Syntax-highlighted viewer (bat)' } + @{ Name = 'ff'; Category = 'Files'; Synopsis = 'Find files recursively' } + @{ Name = 'nf'; Category = 'Files'; Synopsis = 'Create new file' } + @{ Name = 'touch'; Category = 'Files'; Synopsis = 'Create file or update timestamp' } + @{ Name = 'mkcd'; Category = 'Files'; Synopsis = 'Create dir and cd into it' } + @{ Name = 'trash'; Category = 'Files'; Synopsis = 'Move to Recycle Bin' } + @{ Name = 'extract'; Category = 'Files'; Synopsis = 'Universal archive extractor' } + @{ Name = 'file'; Category = 'Files'; Synopsis = 'Identify file type via magic bytes' } + @{ Name = 'sizeof'; Category = 'Files'; Synopsis = 'Human-readable size' } + @{ Name = 'docs'; Category = 'Files'; Synopsis = 'Jump to Documents' } + @{ Name = 'dtop'; Category = 'Files'; Synopsis = 'Jump to Desktop' } + @{ Name = 'cdb'; Category = 'Files'; Synopsis = 'cd back N entries in history (default 1)' } + @{ Name = 'cdh'; Category = 'Files'; Synopsis = 'List the cd history stack' } + @{ Name = 'duration'; Category = 'Profile'; Synopsis = 'Show elapsed time of the last command' } + @{ Name = 'Test-ProfileHealth'; Category = 'Profile'; Synopsis = 'Diagnose install: tools, caches, fonts, PATH, modules' } + @{ Name = 'psp-doctor'; Category = 'Profile'; Synopsis = 'Alias for Test-ProfileHealth' } + @{ Name = 'bak'; Category = 'Files'; Synopsis = 'Timestamped backup' } + @{ Name = 'grep'; Category = 'Unix'; Synopsis = 'Search for pattern in files' } + @{ Name = 'head'; Category = 'Unix'; Synopsis = 'First n lines' } + @{ Name = 'tail'; Category = 'Unix'; Synopsis = 'Last n lines' } + @{ Name = 'sed'; Category = 'Unix'; Synopsis = 'Find and replace in file' } + @{ Name = 'which'; Category = 'Unix'; Synopsis = 'Show command path' } + @{ Name = 'pgrep'; Category = 'Unix'; Synopsis = 'List processes by name' } + @{ Name = 'pkill'; Category = 'Unix'; Synopsis = 'Kill processes by name' } + @{ Name = 'export'; Category = 'Unix'; Synopsis = 'Set environment variable' } + @{ Name = 'env'; Category = 'System'; Synopsis = 'Search/list environment variables' } + @{ Name = 'admin'; Category = 'System'; Synopsis = 'Open elevated terminal' } + @{ Name = 'pubip'; Category = 'System'; Synopsis = 'Public IP' } + @{ Name = 'localip'; Category = 'System'; Synopsis = 'Local IPv4 addresses' } + @{ Name = 'uptime'; Category = 'System'; Synopsis = 'System uptime' } + @{ Name = 'sysinfo'; Category = 'System'; Synopsis = 'Detailed system info' } + @{ Name = 'df'; Category = 'System'; Synopsis = 'Disk volumes' } + @{ Name = 'svc'; Category = 'System'; Synopsis = 'htop-like process viewer' } + @{ Name = 'path'; Category = 'System'; Synopsis = 'Display PATH entries' } + @{ Name = 'eventlog'; Category = 'System'; Synopsis = 'Last event log entries' } + @{ Name = 'winutil'; Category = 'System'; Synopsis = 'Fetch Chris Titus WinUtil (safe-by-default; -ExpectedSha256/-Force to run)' } + @{ Name = 'harden'; Category = 'System'; Synopsis = 'Open Harden Windows Security (prompts before launch)' } + @{ Name = 'hosts'; Category = 'System'; Synopsis = 'Open hosts file (elevated)' } + @{ Name = 'wifipass'; Category = 'System'; Synopsis = 'Show saved WiFi passwords' } + @{ Name = 'journal'; Category = 'Sysadmin'; Synopsis = 'Tail Windows Event Log' } + @{ Name = 'lsblk'; Category = 'Sysadmin'; Synopsis = 'List disks and partitions' } + @{ Name = 'htop'; Category = 'Sysadmin'; Synopsis = 'Interactive process viewer' } + @{ Name = 'mtr'; Category = 'Sysadmin'; Synopsis = 'Traceroute + per-hop ping' } + @{ Name = 'fwallow'; Category = 'Sysadmin'; Synopsis = 'Quick firewall allow rule (supports -WhatIf/-Confirm)' } + @{ Name = 'fwblock'; Category = 'Sysadmin'; Synopsis = 'Quick firewall block rule (supports -WhatIf/-Confirm)' } + @{ Name = 'flushdns'; Category = 'Network'; Synopsis = 'Clear DNS cache' } + @{ Name = 'ports'; Category = 'Network'; Synopsis = 'Listening TCP ports' } + @{ Name = 'checkport'; Category = 'Network'; Synopsis = 'Test TCP connectivity' } + @{ Name = 'portscan'; Category = 'Network'; Synopsis = 'Quick TCP port scan' } + @{ Name = 'tlscert'; Category = 'Network'; Synopsis = 'TLS certificate details' } + @{ Name = 'ipinfo'; Category = 'Network'; Synopsis = 'IP geolocation lookup' } + @{ Name = 'whois'; Category = 'Network'; Synopsis = 'WHOIS domain lookup' } + @{ Name = 'nslook'; Category = 'Network'; Synopsis = 'DNS lookup' } + @{ Name = 'weather'; Category = 'Network'; Synopsis = 'Weather lookup' } + @{ Name = 'speedtest'; Category = 'Network'; Synopsis = 'Download speed test' } + @{ Name = 'hash'; Category = 'Cybersec'; Synopsis = 'File hash (default SHA256)' } + @{ Name = 'checksum'; Category = 'Cybersec'; Synopsis = 'Verify file hash' } + @{ Name = 'genpass'; Category = 'Cybersec'; Synopsis = 'Random password (clipboard)' } + @{ Name = 'b64'; Category = 'Cybersec'; Synopsis = 'Base64 encode' } + @{ Name = 'b64d'; Category = 'Cybersec'; Synopsis = 'Base64 decode' } + @{ Name = 'jwtd'; Category = 'Cybersec'; Synopsis = 'Decode JWT' } + @{ Name = 'uuid'; Category = 'Cybersec'; Synopsis = 'Generate UUID' } + @{ Name = 'urlencode'; Category = 'Cybersec'; Synopsis = 'URL encode' } + @{ Name = 'urldecode'; Category = 'Cybersec'; Synopsis = 'URL decode' } + @{ Name = 'epoch'; Category = 'Cybersec'; Synopsis = 'Unix timestamp converter' } + @{ Name = 'vtscan'; Category = 'Cybersec'; Synopsis = 'VirusTotal hash lookup (-Upload submits)' } + @{ Name = 'nscan'; Category = 'Cybersec'; Synopsis = 'Nmap wrapper' } + @{ Name = 'sigcheck'; Category = 'Cybersec'; Synopsis = 'Authenticode signature details' } + @{ Name = 'ads'; Category = 'Cybersec'; Synopsis = 'Alternate data streams' } + @{ Name = 'defscan'; Category = 'Cybersec'; Synopsis = 'Defender scan wrapper' } + @{ Name = 'pwnd'; Category = 'Cybersec'; Synopsis = 'HIBP breach check' } + @{ Name = 'certcheck'; Category = 'Cybersec'; Synopsis = 'TLS cert chain + pinning' } + @{ Name = 'entropy'; Category = 'Cybersec'; Synopsis = 'Shannon entropy' } + @{ Name = 'killport'; Category = 'Developer'; Synopsis = 'Kill process on TCP port' } + @{ Name = 'Stop-ListeningPort'; Category = 'Developer'; Synopsis = 'Interactive picker for listening ports (alias: killports)' } + @{ Name = 'Find-FileLocker'; Category = 'Sysadmin'; Synopsis = 'Show processes holding a file/folder lock' } + @{ Name = 'Stop-StuckProcess'; Category = 'Sysadmin'; Synopsis = 'Escalating kill: Stop-Process -> taskkill /F -> /F /T' } + @{ Name = 'Remove-LockedItem'; Category = 'Sysadmin'; Synopsis = 'Find lockers, kill, and delete (combo)' } + @{ Name = 'http'; Category = 'Developer'; Synopsis = 'HTTP requests with auto JSON format' } + @{ Name = 'prettyjson'; Category = 'Developer'; Synopsis = 'Pretty-print JSON' } + @{ Name = 'hb'; Category = 'Developer'; Synopsis = 'Upload to hastebin' } + @{ Name = 'timer'; Category = 'Developer'; Synopsis = 'Measure execution time' } + @{ Name = 'watch'; Category = 'Developer'; Synopsis = 'Repeat command every n seconds' } + @{ Name = 'serve'; Category = 'Developer'; Synopsis = 'One-line HTTP server' } + @{ Name = 'envload'; Category = 'Developer'; Synopsis = 'Load .env file' } + @{ Name = 'tldr'; Category = 'Developer'; Synopsis = 'Quick command examples' } + @{ Name = 'repeat'; Category = 'Developer'; Synopsis = 'Repeat scriptblock N times' } + @{ Name = 'mkvenv'; Category = 'Developer'; Synopsis = 'Create + activate Python venv' } + @{ Name = 'outline'; Category = 'Detection'; Synopsis = 'AST outline of a .ps1 file' } + @{ Name = 'psym'; Category = 'Detection'; Synopsis = 'Symbol search across .ps1 files' } + @{ Name = 'lint'; Category = 'Detection'; Synopsis = 'PSScriptAnalyzer wrapper with presets' } + @{ Name = 'Find-DeadCode'; Category = 'Detection'; Synopsis = 'Unused params / uncalled fns' } + @{ Name = 'Get-PwshVersions'; Category = 'Detection'; Synopsis = 'All installed PowerShell versions' } + @{ Name = 'modinfo'; Category = 'Detection'; Synopsis = 'Module details' } + @{ Name = 'psgrep'; Category = 'Detection'; Synopsis = 'AST-based code search' } + @{ Name = 'Copy-SshKey'; Category = 'SSH'; Synopsis = 'Copy SSH key to remote' } + @{ Name = 'wsl'; Category = 'WSL'; Synopsis = 'Wraps wsl.exe; shows distro in tab title' } + @{ Name = 'Get-WslDistro'; Category = 'WSL'; Synopsis = 'List installed distros with state + version' } + @{ Name = 'Enter-WslHere'; Category = 'WSL'; Synopsis = 'Open WSL in current Windows directory (alias: wsl-here)' } + @{ Name = 'ConvertTo-WslPath'; Category = 'WSL'; Synopsis = 'Windows -> WSL path (wslpath -a)' } + @{ Name = 'ConvertTo-WindowsPath'; Category = 'WSL'; Synopsis = 'WSL -> Windows path (wslpath -w)' } + @{ Name = 'Stop-Wsl'; Category = 'WSL'; Synopsis = 'Shutdown all distros or terminate one' } + @{ Name = 'Get-WslIp'; Category = 'WSL'; Synopsis = 'Get IPv4 of a running distro' } + @{ Name = 'Get-WslFile'; Category = 'WSL'; Synopsis = 'List files inside a distro via UNC; pipe-friendly' } + @{ Name = 'Show-WslTree'; Category = 'WSL'; Synopsis = 'Tree view of a distro path (alias: wsl-tree)' } + @{ Name = 'Open-WslExplorer'; Category = 'WSL'; Synopsis = 'Open distro in Windows Explorer (alias: wsl-explorer)' } + @{ Name = 'keygen'; Category = 'SSH'; Synopsis = 'Generate ed25519 key pair' } + @{ Name = 'rdp'; Category = 'SSH'; Synopsis = 'Launch RDP session' } + @{ Name = 'cpy'; Category = 'Clipboard'; Synopsis = 'Copy text to clipboard' } + @{ Name = 'pst'; Category = 'Clipboard'; Synopsis = 'Paste from clipboard' } + @{ Name = 'icb'; Category = 'Clipboard'; Synopsis = 'Insert clipboard into prompt' } + @{ Name = 'Get-ProfileCommand'; Category = 'Extensibility'; Synopsis = 'Query command registry' } + @{ Name = 'Start-ProfileTour'; Category = 'Extensibility'; Synopsis = 'Interactive walkthrough' } + @{ Name = 'Register-ProfileHook'; Category = 'Extensibility'; Synopsis = 'Hook lifecycle events' } + @{ Name = 'Register-HelpSection'; Category = 'Extensibility'; Synopsis = 'Add section to Show-Help' } + @{ Name = 'Register-ProfileCommand'; Category = 'Extensibility'; Synopsis = 'Register a command for discovery' } + @{ Name = 'Add-TrustedDirectory'; Category = 'Extensibility'; Synopsis = 'Trust a dir for .psprc.ps1 auto-load' } + @{ Name = 'Remove-TrustedDirectory'; Category = 'Extensibility'; Synopsis = 'Remove trusted directory' } + @{ Name = 'Set-TerminalBackground'; Category = 'Theme'; Synopsis = 'Set/clear WT background image (live + persisted)' } +) +foreach ($entry in $script:_seedCommands) { + $script:PSP.Commands.Add([PSCustomObject]$entry) +} +Remove-Variable -Name _seedCommands -Scope Script -ErrorAction SilentlyContinue + +# Consume user-settings.json: feature toggles, command overrides, trusted directories. +# Loaded BEFORE profile_user.ps1 so explicit PS-level definitions there win over JSON +# commandOverrides and feature toggles (documented precedence: user-settings.json < +# profile_user.ps1 < plugins -- "most powerful" loads last). +$userSettingsPath = Join-Path $cacheDir 'user-settings.json' +$script:UserSettings = $null +if (Test-Path $userSettingsPath) { + try { + $_rawSettings = Get-Utf8FileText $userSettingsPath + if (-not [string]::IsNullOrWhiteSpace($_rawSettings)) { + $script:UserSettings = $_rawSettings | ConvertFrom-Json -ErrorAction Stop + } + } + catch { Write-Warning "user-settings.json unreadable: $($_.Exception.Message)" } +} +if ($script:UserSettings) { + # Authoritative feature apply (also covers the case where the early PSReadLine-block apply was skipped/failed). + Set-PspFeatureOverride $script:UserSettings + if ($script:UserSettings.PSObject.Properties['trustedDirs']) { + foreach ($d in @($script:UserSettings.trustedDirs | Where-Object { $_ })) { + # Normalize to the canonical ProviderPath form that Add-TrustedDirectory stores and the cd-hook + # compares against ($PWD.ProviderPath), so a hand-edited entry with a trailing slash or relative + # form still matches. Falls back to the raw value when the path can't be resolved yet. + $norm = try { (Resolve-Path -LiteralPath ([string]$d) -ErrorAction Stop).ProviderPath } catch { [string]$d } + [void]$script:PSP.TrustedDirs.Add($norm) + } + } + if ($script:UserSettings.PSObject.Properties['commandOverrides']) { + $_overrideCount = @($script:UserSettings.commandOverrides.PSObject.Properties).Count + if (-not $script:PSP.Features.commandOverrides) { + # Security-significant feature: commandOverrides compiles JSON strings to scriptblocks. + # Default-off; warn loudly if the user has entries but hasn't enabled the feature. + if ($_overrideCount -gt 0 -and $isInteractive -and $script:PSP.Features.startupMessage) { + Write-Host ("commandOverrides ignored ({0} entries): set features.commandOverrides = true in user-settings.json to apply." -f $_overrideCount) -ForegroundColor DarkYellow + } + } + else { + $_appliedOverrides = @() + foreach ($prop in $script:UserSettings.commandOverrides.PSObject.Properties) { + $_ovName = $prop.Name + # Skip underscore-prefixed keys so documentation markers like `_note` in + # user-settings.json examples never get compiled as real commands. + if ($_ovName -like '_*') { continue } + $_ovBody = [string]$prop.Value + if ([string]::IsNullOrWhiteSpace($_ovBody)) { continue } + try { + Remove-Item "function:$_ovName" -ErrorAction SilentlyContinue + Remove-Item "alias:$_ovName" -ErrorAction SilentlyContinue + Set-Item "function:$_ovName" -Value ([scriptblock]::Create($_ovBody)) + $_appliedOverrides += $_ovName + } + catch { Write-Warning "commandOverride '$_ovName' failed: $($_.Exception.Message)" } + } + # Visible notice every interactive load so JSON-sourced code execution is not invisible. + if ($_appliedOverrides.Count -gt 0 -and $isInteractive -and $script:PSP.Features.startupMessage) { + Write-Host ("commandOverrides active ({0}): {1}" -f $_appliedOverrides.Count, ($_appliedOverrides -join ', ')) -ForegroundColor Yellow + } + } + } } -# User overrides (survives Update-Profile) +# User overrides (survives Update-Profile). Loaded AFTER user-settings.json so explicit PS-level +# definitions win over JSON commandOverrides/feature toggles, and BEFORE plugins so a plugin can +# still intentionally override a user-defined function. $userProfile = Join-Path (Split-Path $PROFILE) "profile_user.ps1" if (Test-Path $userProfile) { try { . $userProfile } catch { Write-Warning "Failed to load profile_user.ps1: $_" } } +# Auto-load plugins from $cacheDir\plugins\*.ps1. Dot-sourced so they inherit script scope +# and can call Register-* APIs freely. Errors are isolated per plugin. +$pluginDir = Join-Path $cacheDir 'plugins' +if (-not (Test-Path $pluginDir)) { + try { New-Item -ItemType Directory -Path $pluginDir -Force | Out-Null } catch { $null = $_ } +} +if (Test-Path $pluginDir) { + foreach ($plugin in (Get-ChildItem -Path $pluginDir -Filter *.ps1 -ErrorAction SilentlyContinue | Sort-Object Name)) { + try { . $plugin.FullName } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning "Plugin '$($plugin.Name)' failed to load: $($_.Exception.Message)" + } + } +} + +# Update-check (opt-in). Fires at most once every 7 days when features.updateCheck = true +# and $isInteractive. Hits GitHub's commits API (~1 KB response) with a 3-second timeout so +# a slow network does not stall profile load. The first check writes the current main SHA +# as a baseline and only informs; subsequent checks notify when main has moved past that +# baseline. Update-Profile refreshes the baseline on successful apply. +if ($isInteractive -and $script:PSP.Features.updateCheck) { + try { + $_ucStampFile = Join-Path $cacheDir 'last-update-check.txt' + $_ucDoCheck = $true + if (Test-Path $_ucStampFile) { + $_ucRaw = (Get-Content $_ucStampFile -Raw -ErrorAction SilentlyContinue).Trim() + $_ucLast = [datetime]::MinValue + if ([datetime]::TryParse($_ucRaw, [ref]$_ucLast)) { + if (((Get-Date) - $_ucLast).TotalDays -lt 7) { $_ucDoCheck = $false } + } + } + if ($_ucDoCheck) { + $_ucLatest = Get-LatestMainCommitSha + if ($_ucLatest) { + $_ucBaselineFile = Join-Path $cacheDir 'applied-commit.sha' + $_ucStored = if (Test-Path $_ucBaselineFile) { (Get-Content $_ucBaselineFile -Raw -ErrorAction SilentlyContinue).Trim() } else { $null } + if (-not $_ucStored) { + # First check: record baseline so future checks can compare without false negatives. + [System.IO.File]::WriteAllText($_ucBaselineFile, $_ucLatest, [System.Text.UTF8Encoding]::new($false)) + Write-Host ("Update-check enabled. Baseline: {0}. You'll see a notification here when main moves past this commit." -f $_ucLatest.Substring(0, 7)) -ForegroundColor DarkGray + } + elseif ($_ucStored -ne $_ucLatest) { + Write-Host ("Update available: main is at {0}... (applied {1}...). Run: Update-Profile" -f $_ucLatest.Substring(0, 7), $_ucStored.Substring(0, 7)) -ForegroundColor Yellow + } + # Only stamp on successful API response; a silent API failure should not + # lock the user out of update notifications for 7 days. + [System.IO.File]::WriteAllText($_ucStampFile, (Get-Date).ToString('o'), [System.Text.UTF8Encoding]::new($false)) + } + } + } + catch { $null = $_ } +} + +# Fire OnProfileLoad hooks (user/plugin extensions). +Invoke-ProfileHook -EventName 'OnProfileLoad' + # Startup complete - show load time $profileStopwatch.Stop() -if ($isInteractive) { +if ($isInteractive -and $script:PSP.Features.startupMessage) { Write-Host "Profile loaded in $($profileStopwatch.ElapsedMilliseconds)ms." -ForegroundColor DarkGray - Write-Host "Use 'Show-Help' to display help" -ForegroundColor Yellow + Write-Host "Use 'Show-Help' or 'Start-ProfileTour' to explore." -ForegroundColor Yellow } diff --git a/README.md b/README.md index 25cdcf2..0831232 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,133 @@ # PowerShellPerfect [![CI](https://github.com/26zl/PowerShellPerfect/actions/workflows/ci.yml/badge.svg)](https://github.com/26zl/PowerShellPerfect/actions/workflows/ci.yml) +[![PowerShell 5.1+](https://img.shields.io/badge/PowerShell-5.1%20%7C%207%2B-5391FE?logo=powershell&logoColor=white)](https://github.com/PowerShell/PowerShell) +[![Platform](https://img.shields.io/badge/platform-Windows%2010%20%7C%2011-0078D6?logo=windows&logoColor=white)](https://www.microsoft.com/windows) +[![Status](https://img.shields.io/badge/status-active%20development-orange)](https://github.com/26zl/PowerShellPerfect/commits/main) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](#license) + +> ⚠️ **Under active development.** Interfaces, defaults, and wizard steps may change between commits. Pin a specific commit in `-ExpectedSha256` if you need reproducibility. Bug reports and PRs are welcome. +> A modern PowerShell profile for Windows. The installer drops you into a **`p10k configure`-style install wizard** that picks your theme, color scheme, font, and feature toggles — then ships 130+ Unix-style commands, a tuned Oh My Posh prompt, fuzzy search, zoxide, and a full uninstall + self-update behind it. + + -A PowerShell profile made to make a CLI nerd's life easier. Brings the Linux terminal experience to Windows - grep, cat, ls with icons, fuzzy search, zoxide, and 60+ utility commands out of the box. One command installs everything and keeps it updated. Works on both PowerShell 5.1 and 7+. +```powershell +# Review-first (recommended): prints the install-bundle SHA256 and STOPS before changing anything. +$setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" +& ([scriptblock]::Create($setup)) + +# Then apply, pinned to the hash the step above printed (reproducible, integrity-checked): +& ([scriptblock]::Create($setup)) -ExpectedSha256 '' +``` + +Run in an **elevated** PowerShell window. The default flow above verifies download integrity before touching your machine. Prefer one step and willing to trust-on-download? Append `-SkipHashCheck`: + +```powershell +& ([scriptblock]::Create($setup)) -SkipHashCheck +``` + +> **What the hash proves:** the printed SHA256 is computed over the bytes you just downloaded, so `-ExpectedSha256` guarantees a *reproducible, untampered-in-transit* apply — not that the bytes match what the maintainer published. For true upstream-authenticity pinning, verify the commit SHA out-of-band (browser / signed tag) before trusting a hash. See [SECURITY.md](SECURITY.md). -**Why use this?** +The **install wizard runs by default** — pick theme / scheme / font / features interactively, or pass `-SkipWizard` for repo defaults. The terminal restarts when setup finishes (new tab in Windows Terminal, or a new window otherwise). For the best experience use [PowerShell 7+](https://github.com/PowerShell/PowerShell). -- **One command, fully configured** - installs 6 tools, Nerd Fonts, Oh My Posh theme, and Windows Terminal settings in one run -- **Self-updating** - profile, theme, and terminal config sync from upstream with hash verification -- **AI/CI sandbox safe** - detects non-interactive environments and suppresses network calls and UI setup automatically -- **PS5 + PS7** - installs to both profile directories and handles every API difference between editions -- **Hardened** - sensitive commands filtered from PSReadLine history; no secrets in source -- **Fast startup** - prompt renders directly from a local Oh My Posh theme; only zoxide init is cached -- **Survives updates** - personal overrides in `profile_user.ps1` and `user-settings.json` are never touched +## Contents -Originally forked and inspired by [ChrisTitusTech/powershell-profile](https://github.com/ChrisTitusTech/powershell-profile). +- [Requirements](#requirements) · [Install Wizard](#install-wizard) · [At a glance](#at-a-glance) · [Install (alternatives)](#install-alternatives) +- [Updates](#updates) · [Uninstall](#uninstall) · [Customization](#customization) · [Keyboard Shortcuts](#keyboard-shortcuts) +- [Commands](#commands) · [Compared to alternatives](#compared-to-alternatives) · [Troubleshooting](#troubleshooting) · [Tests](#tests) · [Roadmap](#roadmap) -## Install +## Requirements -Run in an **elevated** PowerShell window: +| | | +| --- | --- | +| **OS** | Windows 10 / 11 (Windows Terminal recommended) | +| **Shell** | Windows PowerShell 5.1 (works) or [PowerShell 7+](https://github.com/PowerShell/PowerShell) (recommended; every PS5/PS7 API fork is guarded) | +| **Privileges** | Elevated session for install/uninstall (font + machine-scope steps). Day-to-day commands need no admin. | +| **Network** | First install fetches the profile bundle + (optionally) Oh My Posh, a Nerd Font, and CLI tools via winget. TLS 1.2+ is enforced on PS5.1. | +| **Optional tools** | `eza`, `bat`, `ripgrep`, `fzf`, `zoxide`, `oh-my-posh` — installed by setup; every command degrades gracefully if a tool is absent. | + +## Install Wizard + +Inspired by [powerlevel10k](https://github.com/romkatv/powerlevel10k)'s `p10k configure`. **Runs automatically on every interactive install** — it's the default experience, not an opt-in. CI and AI-agent environments are auto-detected and skip it. ```powershell -irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" | iex +# Default trusted-download flow — wizard runs as part of installation +$setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" +& ([scriptblock]::Create($setup)) -SkipHashCheck + +# Bypass the wizard and apply repo defaults (Tokyo Night + CascadiaCode): +.\setup.ps1 -SkipWizard + +# Resume a half-finished wizard (state persisted in %TEMP%\psp-wizard-state.json): +.\setup.ps1 -Resume + +# Re-run the wizard any time after install (downloads latest setup.ps1 + elevates): +Reconfigure-Profile ``` -The terminal restarts automatically when setup finishes (new tab in Windows Terminal, or new window otherwise). For the best experience use [PowerShell 7](https://github.com/PowerShell/PowerShell). +**Steps** (all accept Enter to keep the default / skip): + +1. **Quick start** — one-key preset: Tokyo Night scheme, CascadiaCode Nerd Font, VS Code editor, dark chrome, default features. Answer yes to jump straight to the summary. +2. **Oh My Posh theme** — live fetch from [JanDeDobbeleer/oh-my-posh/themes](https://github.com/JanDeDobbeleer/oh-my-posh) via GitHub API. Pick by number or partial name. Network failure falls back to `pure`. +3. **Color scheme** — curated 7-pack: Tokyo Night (default), Gruvbox Dark, Dracula, Catppuccin Mocha, Nord, One Half Dark, Solarized Dark. Full scheme definitions embedded; no extra network. +4. **Nerd Font** — Caskaydia, JetBrainsMono, FiraCode, Meslo, Hack, or Iosevka. Fetches latest release tag from [ryanoasis/nerd-fonts](https://github.com/ryanoasis/nerd-fonts/releases) automatically. +5. **Tab bar color + window chrome** — presets (scheme-match, pure black, custom hex) + `applicationTheme` dark/light. +6. **Terminal appearance** — opacity, `useAcrylic`, font size, cursor shape, padding, scrollbar state, history size. Each prompt keeps the current default on Enter. +7. **PSReadLine colors** — default / derive from chosen scheme / skip. +8. **Background image** — optional path + opacity (0.05–0.50). Skipped by default. +9. **Editor preference** — VS Code, Notepad++, Neovim, Vim, Notepad, or a custom exe. Used by `edit`, `ep`, `hosts`, etc. +10. **Telemetry opt-out + feature toggles** — `psfzf`, `predictions`, `startupMessage`, `perDirProfiles`, `commandOverrides` — y/n per item with sensible defaults. + +**Design**: -> **Recommended for Oh My Posh:** Install the x64 MSI from the [releases](https://github.com/JanDeDobbeleer/oh-my-posh/releases) page (see [Oh My Posh](https://github.com/JanDeDobbeleer/oh-my-posh)) instead of `winget`/Store—this profile preserves a direct install and avoids the WindowsApps path. If you already have the MSI install, setup leaves it as is. +- All choices persist to `user-settings.json` so `Update-Profile` re-applies them; nothing hardcoded into the profile. +- Summary screen at the end with "apply all?" confirmation. +- State file enables `-Resume` if the wizard is interrupted or cancelled. +- All 130+ commands and the extensibility system ship regardless of wizard choices; the wizard only selects cosmetics and opt-ins. -### Manual Setup +## At a glance + +| | | +| --- | --- | +| **130+ commands** | git, files, unix tools, network, security, developer, sysadmin, WSL, docker, ssh, clipboard | +| **Install wizard (default)** | Runs automatically when setup is invoked. Picks OMP theme, WT color scheme (7 curated), Nerd Font (6 curated), tab-bar + window chrome, terminal appearance, PSReadLine colors, background, editor, telemetry opt-out, feature toggles. `-Resume` on interrupt. See [Install Wizard](#install-wizard) for details. | +| **Transient prompt** | Scrollback shows collapsed `$`; new input gets the full OMP prompt (opt-in feature flag) | +| **Self-updating** | `Update-Profile` syncs profile + theme + WT config with SHA-256 verification. Survives custom `profile_user.ps1` + `user-settings.json`. | +| **Full uninstall** | `Uninstall-Profile` restores WT, removes caches, `-RemoveTools` drops winget packages, `-All` wipes everything | +| **PS5 + PS7** | Installs to both profile directories; every PS5/PS7 API fork is guarded | +| **Sandbox-safe** | CI + AI agents auto-detected; network calls and UI setup suppressed so sessions don't hang | +| **Hardened** | Passwords/tokens filtered from PSReadLine history; `Merge-JsonObject` + WT settings merge are unit-tested in CI | +| **Tested** | Lint, PS5 parse, 100% command-coverage audit, full install + uninstall sandbox - all run on every PR | + +Inspired by [ChrisTitusTech/powershell-profile](https://github.com/ChrisTitusTech/powershell-profile); design cues from [powerlevel10k](https://github.com/romkatv/powerlevel10k) and [starship](https://github.com/starship/starship). + +## Install (alternatives) + +The one-liner at the top is the recommended path. These are alternatives when you need a local clone or want to tweak defaults without going through the wizard. + +> **Recommended for Oh My Posh:** Install the x64 MSI from the [releases](https://github.com/JanDeDobbeleer/oh-my-posh/releases) page (see [Oh My Posh](https://github.com/JanDeDobbeleer/oh-my-posh)) instead of `winget`/Store — this profile preserves a direct install and avoids the WindowsApps path. If you already have the MSI install, setup leaves it as is. + +### Manual Setup (local clone) ```powershell git clone https://github.com/26zl/PowerShellPerfect.git cd PowerShellPerfect -.\setup.ps1 -.\setprofile.ps1 +.\setup.ps1 # wizard runs by default +.\setup.ps1 -SkipWizard # apply repo defaults instead ``` -When running locally you can override terminal defaults (not available via `irm | iex`): +`setup.ps1` auto-detects the local clone when run from the repo directory, so the profile, `theme.json`, and `terminal-config.json` are copied from your working tree instead of downloaded from GitHub. It installs the profile to both PS5 and PS7 directories as part of step [1/10]; a separate `.\setprofile.ps1` run is only needed if you later want a quick profile-only refresh without re-running the full installer. + +When running locally you can override terminal defaults: ```powershell -.\setup.ps1 -Opacity 85 -ColorScheme "One Half Dark" -FontSize 12 +.\setup.ps1 -SkipWizard -Opacity 85 -ColorScheme "One Half Dark" -FontSize 12 ``` > **Controlled Folder Access:** If Windows Defender blocks the setup, allow PowerShell through: @@ -58,7 +145,7 @@ Update-PowerShell # Check for new PowerShell 7 releases Update-Tools # Update winget-managed tools; direct/MSI Oh My Posh installs are preserved ``` -`Update-Profile` requires hash verification by default. Confirm with `-ExpectedSha256 ''`, or use `-SkipHashCheck` to bypass. Use `-Force` to re-apply settings even when nothing changed upstream. +`Update-Profile` requires hash input by default — either pass the hash the previous run printed as `-ExpectedSha256 ''` (ensures file integrity and a reproducible apply) or `-SkipHashCheck` to bypass. For actual trust pinning against a specific upstream commit, verify the commit SHA out-of-band (browser, signed tag) before using `-ExpectedSha256`; the hash the tool prints for a first-time download is computed over what was just fetched, so it confirms "this is what I just downloaded" but not "this is what upstream really published". Use `-Force` to re-apply settings even when nothing changed upstream. ## Uninstall @@ -71,16 +158,32 @@ Uninstall-Profile -All # Remove everything including tools, fonts, and u Uninstall-Profile -All -HardResetWindowsTerminal # Same as -All, but also delete WT settings.json so WT recreates factory defaults ``` -Optional switches: `-RemoveTools` (winget-managed tools plus direct/MSI Oh My Posh when registered as MSI), `-RemoveUserData` (profile_user.ps1, user-settings.json), `-RemoveFonts` (Nerd Fonts, requires admin), `-All` (everything), `-HardResetWindowsTerminal` (delete WT settings.json and backups so Windows Terminal recreates defaults). Supports `-WhatIf` to preview without making changes. +Optional switches: `-RemoveTools` (winget-managed tools plus direct/MSI Oh My Posh when registered as MSI), `-RemoveUserData` (`profile_user.ps1`, `user-settings.json`, and your `plugins/`), `-RemoveFonts` (Nerd Fonts, requires admin), `-All` (everything), `-HardResetWindowsTerminal` (delete WT settings.json and backups so Windows Terminal recreates defaults). Supports `-WhatIf` to preview without making changes. A plain `Uninstall-Profile` preserves `user-settings.json`, `profile_user.ps1`, and your `plugins/` — only `-RemoveUserData`/`-All` delete them. ## Customization -Two files survive updates and override everything: +Four extension points survive updates. From simplest to most powerful: -- **`profile_user.ps1`** (`Split-Path $PROFILE`) - PowerShell overrides: aliases, functions, editor, colors, modules -- **`user-settings.json`** (`%LOCALAPPDATA%\PowerShellProfile\`) - Terminal overrides: theme, opacity, font, keybindings +- **`user-settings.json`** (`%LOCALAPPDATA%\PowerShellProfile\`) - JSON overrides. Keys: + - `theme`, `windowsTerminal`, `defaults`, `keybindings` - terminal and OMP theme + - `defaults.backgroundImage` / `backgroundImageOpacity` / `backgroundImageStretchMode` / `backgroundImageAlignment` - Windows Terminal background image + - `features` - toggle heavy/optional behavior: `psfzf`, `predictions`, `startupMessage`, `perDirProfiles` (all `true` by default), `transientPrompt` (collapses previous prompt on Enter; default `false`, customize via `$script:PSP.TransientPrompt = { ... }` in `profile_user.ps1`), `updateCheck` (notifies once a week when main has advanced past the applied commit; default `false` so `irm | iex` in scripts does not trigger a surprise network call) + - `commandOverrides` - redefine any command without editing source: `{ "gs": "git status --short" }`. Opt-in: set `features.commandOverrides = true` in the same file. Default off because it compiles JSON strings into executable scriptblocks. + - `trustedDirs` - directories whose `.psprc.ps1` auto-loads (managed by `Add-TrustedDirectory`) +- **`profile_user.ps1`** (`Split-Path $PROFILE`) - PowerShell overrides (aliases, functions, editor, colors, modules), dot-sourced after `user-settings.json` (so PS-level definitions win over JSON `commandOverrides` and feature toggles) and before `plugins/` +- **`plugins/*.ps1`** (`%LOCALAPPDATA%\PowerShellProfile\plugins\`) - drop-in plugins. Each file is auto-loaded; errors are isolated per plugin +- **`.psprc.ps1`** (per directory) - project-specific profile. Auto-loads on `cd` into a directory registered with `Add-TrustedDirectory`. Use `function global:foo` / `Set-Alias -Scope Global` for lasting definitions; `$env:VAR` always persists -Both are created automatically during setup. `profile_user.ps1` is dot-sourced last so your settings always win. +Call `Start-ProfileTour` for a live walkthrough, or `Get-ProfileCommand -Category ` to list what's available. + +### Background Image + +```powershell +Set-TerminalBackground "$env:USERPROFILE\Pictures\bg.png" -Opacity 0.1 +Set-TerminalBackground -Clear +``` + +Default fills the whole tab with low opacity (typical backdrop). For a small corner watermark, add `-ResizeWidth 200 -StretchMode none -Alignment bottomRight`. Persisted to `user-settings.json`, applied live to WT. ## Keyboard Shortcuts @@ -111,6 +214,7 @@ Run `Show-Help` in your terminal for a colored version of this list. | `Update-Profile` | Sync profile, theme, caches, and WT settings | | `Update-PowerShell` | Check for new PowerShell 7 releases | | `Update-Tools` | Update winget-managed tools; direct/MSI Oh My Posh installs are preserved | +| `Invoke-ProfileWizard` / `Reconfigure-Profile` | Re-run the install wizard to pick a new theme / scheme / font / feature set | | `reload` | Reload the PowerShell profile | | `Show-Help` | Show help in terminal | | `Uninstall-Profile` | Remove profile, caches, and WT changes (`-All` for everything) | @@ -143,6 +247,8 @@ Run `Show-Help` in your terminal for a colored version of this list. | `file ` | Identify file type via magic bytes | | `sizeof ` | Human-readable file/directory size | | `docs` / `dtop` | Jump to Documents / Desktop | +| `cdb [N]` | cd back N entries in history (default 1, previous dir) | +| `cdh` | List the cd history stack (most-recent first) | ### Unix-like @@ -185,8 +291,23 @@ Run `Show-Help` in your terminal for a colored version of this list. | `hosts` | Open hosts file in elevated editor | | `Clear-Cache` [-IncludeSystemCaches] | Clear user temp/browser caches (optionally system dirs) | | `Clear-ProfileCache` | Reset profile caches plus OMP internal caches | -| `winutil` | Launch [Chris Titus WinUtil](https://github.com/ChrisTitusTech/winutil) | -| `harden` | Open [Harden Windows Security](https://github.com/HotCakeX/Harden-Windows-Security) | +| `duration` | Show elapsed time of the last executed command | +| `Test-ProfileHealth` / `psp-doctor` | Diagnose install (tools, caches, fonts, PATH, modules) | +| `winutil [-ExpectedSha256 ]` / `winutil -Force` | Fetch [Chris Titus WinUtil](https://github.com/ChrisTitusTech/winutil). Safe-by-default: prints source URL and SHA256, then stops. Re-run with `-ExpectedSha256 ''` (hash-pinned) or `-Force` (trust without verification) to stage execution, and PowerShell still asks for a high-impact confirmation before launch. | +| `harden` | Open [Harden Windows Security](https://github.com/HotCakeX/Harden-Windows-Security) with an explicit confirmation prompt before launch. | + +### Sysadmin + +| Command | Description | +| --- | --- | +| `journal [log] [-Count n] [-Follow] [-Level ...]` | Tail Windows Event Log (journalctl-style) | +| `lsblk` | List disks and partitions with volume info | +| `htop` | Interactive process viewer (uses btop/ntop/htop if installed, else svc -Live) | +| `mtr ` | Traceroute with per-hop ping stats | +| `fwallow` / `fwblock [-Port n]` | Quick Windows Firewall rule (needs admin; supports `-WhatIf` / `-Confirm`) | +| `Find-FileLocker ` | Show processes holding a file/folder lock (uses Windows Restart Manager API) | +| `Stop-StuckProcess ` | Escalating kill: `Stop-Process -Force` → `taskkill /F` → `/F /T` for processes that ignore normal kill | +| `Remove-LockedItem [-Recurse]` | Find lockers, kill them, then delete. For "file is in use" errors | ### Security & Crypto @@ -200,20 +321,62 @@ Run `Show-Help` in your terminal for a colored version of this list. | `uuid` | Generate random UUID (copies to clipboard) | | `epoch [value]` | Unix timestamp converter (no args = now) | | `urlencode` / `urldecode ` | URL encode / decode | -| `vtscan ` | VirusTotal scan + open in browser | +| `vtscan [-Upload]` | VirusTotal hash lookup; `-Upload` submits unknown files | | `vt ` | Full VirusTotal CLI (vt-cli) | +| `nscan [-Mode ...]` | Nmap wrapper with curated scan profiles (Quick/Full/Services/Stealth/Vuln/Ports) | +| `sigcheck ` | Authenticode signature details (file or directory) | +| `ads ` | List NTFS alternate data streams | +| `defscan [path] [-Mode Quick/Full]` | Windows Defender scan wrapper | +| `pwnd ` | HIBP k-anonymity breach lookup (only first 5 SHA1 chars leave the host) | +| `certcheck [port]` | Full TLS probe: chain, SAN, SHA256 pin, cipher | +| `entropy ` | Shannon entropy (detect packed/encrypted payloads) | ### Developer | Command | Description | | --- | --- | | `killport ` | Kill process on a TCP port | +| `killports` (alias for `Stop-ListeningPort`) | Interactive fzf picker: lists all listening ports (port/PID/process), Tab for multi-select, Enter to kill | | `http [-Method POST] [-Body '...']` | HTTP requests, auto-formats JSON | | `prettyjson ` | Pretty-print JSON (accepts pipeline input) | | `hb ` | Upload to hastebin, copy URL | | `timer { command }` | Measure execution time | | `watch { command } [-Interval n]` | Repeat command every n seconds (default 2; like Linux watch) | | `bak ` | Quick timestamped backup | +| `serve [port] [path]` | One-line HTTP server (python or npx) | +| `gitignore ` | Generate .gitignore from gitignore.io | +| `gcof` | Fuzzy git branch checkout (fzf) | +| `envload [path]` | Load .env file into current session | +| `tldr ` | Quick command-example lookup (tldr-pages) | +| `repeat { cmd } [-UntilSuccess]` | Repeat a scriptblock | +| `mkvenv [name]` | Create and activate a Python venv | + +### Detection & AST + +Inspired by the PowerShell VSCode extension: AST-powered tools that understand PowerShell code. + +| Command | Description | +| --- | --- | +| `outline ` | List functions/params/aliases via AST parser | +| `psym [pattern] [root]` | Symbol search across .ps1 files | +| `lint [path] [-Mode Standard/Strict/Security/CI] [-Fix]` | PSScriptAnalyzer wrapper with presets | +| `Find-DeadCode ` | Unused params and same-file uncalled functions | +| `Test-Profile` | Profile diagnostics: version, policy, caches, tools, env | +| `Get-PwshVersions` | Enumerate every installed PowerShell | +| `modinfo ` | Module details: path, version(s), exports, signature | +| `psgrep [-Kind Command/Variable/String/Function]` | AST-based code search (structural grep) | + +### Extensibility + +| Command | Description | +| --- | --- | +| `Get-ProfileCommand [-Category ...] [-Name ...]` | Query the command registry | +| `Start-ProfileTour` | Interactive walkthrough of every category | +| `Register-ProfileHook -EventName OnProfileLoad/PrePrompt/OnCd -Action { ... }` | Hook lifecycle events | +| `Register-HelpSection -Title ... -Lines @(...)` | Add a section to `Show-Help` | +| `Register-ProfileCommand -Name ... -Category ... [-Synopsis ...]` | Add to command registry | +| `Add-TrustedDirectory` / `Remove-TrustedDirectory [path]` | Trust a dir so `.psprc.ps1` auto-loads | +| `Set-TerminalBackground [-Opacity] [-StretchMode] [-Alignment]` | Set WT background image (live + persisted); `-Clear` to remove | ### Docker (when installed) @@ -230,10 +393,28 @@ Run `Show-Help` in your terminal for a colored version of this list. | Command | Description | | --- | --- | +| `ssh ` | Wraps `ssh.exe` with `ConnectTimeout=10` + keepalive so hung connects fail fast and respond to Ctrl+C (user `-o` values take precedence) | | `Copy-SshKey` / `ssh-copy-key ` | Copy SSH key to remote (when ssh installed) | | `keygen [name]` | Generate ED25519 key pair (when ssh installed) | | `rdp ` | Launch RDP session | +### WSL (when `wsl.exe` is installed) + +| Command | Description | +| --- | --- | +| `wsl [args]` | Wraps `wsl.exe` and sets tab title to the distro name during the session | +| `Get-WslDistro` | List installed distros with state + version + default flag (pipe-friendly objects) | +| `Enter-WslHere` / `wsl-here [-Distro]` | Open a WSL shell in the current Windows directory (auto path-translated) | +| `Get-WslFile [path] [-Recurse]` | List files inside a distro via the `\\wsl$\` UNC path; returns FileInfo objects | +| `Show-WslTree` / `wsl-tree [path] [-Depth N]` | Tree-view of a distro path (uses `eza` when available) | +| `Open-WslExplorer` / `wsl-explorer [path]` | Open the distro path in Windows Explorer (GUI file browsing) | +| `ConvertTo-WslPath ` | Translate Windows path to WSL (handles backslash-dropping quirk) | +| `ConvertTo-WindowsPath ` | Translate WSL path to Windows | +| `Get-WslIp [-Distro]` | IPv4 of a running distro (for connecting to in-distro services from Windows) | +| `Stop-Wsl [-Distro]` | Shutdown all distros, or terminate one by name | + +Tab-complete works on `-Distro` for all of these via live `Get-WslDistro` lookup. + ### Clipboard | Command | Description | @@ -241,3 +422,55 @@ Run `Show-Help` in your terminal for a colored version of this list. | `cpy ` | Copy to clipboard | | `pst` | Paste from clipboard | | `icb` | Insert clipboard into prompt (never executes) | + +## Tests + +Everything in `tests/` is tracked and runs locally in seconds. + +| File | Run | Purpose | +| --- | --- | --- | +| `tests/lint.ps1` | `pwsh -NoProfile -File tests/lint.ps1` | PSScriptAnalyzer with the exact rule set CI enforces | +| `tests/test.ps1` | `pwsh -NoProfile -File tests/test.ps1` | Full quality gate: lint, PS5 parse, BOM/secret/path scans, install + uninstall sandboxes, 100% command-coverage audit. A trap + `PowerShell.Exiting` handler sweeps any `psp-*` sandbox dirs from `%TEMP%` if you Ctrl+C mid-run. | +| `tests/rawhunt.ps1` | `pwsh -NoProfile -File tests/rawhunt.ps1` | Loads the real profile and exercises every function with real I/O (file ops, network, crypto, git, clipboard, caching, WT settings) | +| `tests/locallab.ps1` | `pwsh -NoProfile -File tests/locallab.ps1 -Wizard` | Dev harness: runs all the above and optionally drives `setup.ps1 -LocalRepo -Wizard` end-to-end; `-Restore` rolls back to the last sandbox backup | +| `tests/ci-functional.ps1` | GitHub Actions `functional` job | What CI runs: full install via `setup.ps1 -LocalRepo`, profile load under `$env:CI`, uninstall sandbox, and a coverage audit that refuses to pass unless every function/alias has an `Invoke-CommandProbe` entry | + +CI (`.github/workflows/ci.yml`) runs three jobs on every push/PR: **lint** (rule set + secret scan + PS5 parse), **install-flow** (JSON config + `Merge-JsonObject` unit tests + curated-scheme/font validation), **functional** (the full end-to-end). Both `lint` and `install-flow` are required status checks. + +## Compared to alternatives + +PowerShellPerfect bundles a **prompt** (via Oh My Posh), a **command suite**, and a **wizard-driven installer** in one. The prompt engines below are great at theming but aren't command suites; ChrisTitusTech's profile is the closest peer. + +| | **PowerShellPerfect** | [ChrisTitusTech](https://github.com/ChrisTitusTech/powershell-profile) | [Oh My Posh](https://ohmyposh.dev) | [Starship](https://starship.rs) | +| --- | :---: | :---: | :---: | :---: | +| p10k-style install wizard | ✅ | — | — | — | +| 130+ Unix-style commands | ✅ | partial | — | — | +| Prompt theming | ✅ (via Oh My Posh) | ✅ (via Oh My Posh) | ✅ (engine) | ✅ (engine) | +| Hash-verified self-update + full uninstall | ✅ | ✅ | n/a | n/a | +| PS 5.1 + PS 7 (guarded forks) | ✅ | ✅ | ✅ | ✅ (cross-shell) | +| Secret-scrubbed history | ✅ | — | — | — | +| Customization surfaces | `user-settings.json` + `profile_user.ps1` + `plugins/` + `.psprc.ps1` | profile edits | themes | `starship.toml` | + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| Setup blocked by Windows Defender (Controlled Folder Access) | Allow PowerShell through (see the command in [Install](#install-alternatives)), or run the clone from a non-protected folder. | +| `running scripts is disabled on this system` | `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` — or use the `-ExecutionPolicy Bypass` the one-liner already applies. | +| Prompt shows boxes / missing glyphs | The terminal font isn't a Nerd Font. Set your Windows Terminal profile font to the one setup installed (e.g. *CaskaydiaCove Nerd Font*) and restart WT. | +| `oh-my-posh` not found right after install | Reopen the terminal (PATH refresh) or run `Update-SessionPathFromRegistry`; confirm with `psp-doctor`. | +| Turned a feature off in the wizard but it still loads | Update to the latest commit — feature toggles now apply before PSReadLine init. Check the `features` block in `user-settings.json`. | +| Something feels off | Run `psp-doctor` (alias for `Test-ProfileHealth`): OK/WARN/FAIL per check across tools, caches, fonts, PATH, and modules. | + +## Roadmap + +Further ideas: + +- Per-distro WSL auto-configuration (install common tools when a new distro is detected). +- `profile_user.ps1` scaffolder (`New-ProfileOverride` generates a commented starter file). +- Auto-fix mode for `psp-doctor` / `Test-ProfileHealth` — today it *diagnoses* (tools, caches, fonts, PATH, modules); add an opt-in `-Fix` that repairs the common issues it surfaces. +- Live theme preview (render OMP themes inline during picker rather than just listing names). + +## License + +MIT. Use it, fork it, rip out what you need. Credit appreciated, not required. diff --git a/SECURITY.md b/SECURITY.md index 89c6d1e..60c2a33 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,18 @@ The following areas are in scope for security reports: ## Security Measures -- `Update-Profile` requires SHA-256 hash verification (or explicit `-SkipHashCheck`) -- PSReadLine history filters out lines containing: `password`, `secret`, `token`, `api[_-]?key`, `connectionstring`, `credential`, `bearer` +- Remote setup and `Update-Profile` require SHA-256 hash input before applying a downloaded install bundle, or explicit `-SkipHashCheck`. `Invoke-ProfileWizard` pins the downloaded `setup.ps1` with `-ExpectedSha256` and forwards the install-bundle pin with `-BundleExpectedSha256`. **What this protects**: file integrity (truncated/corrupted downloads) and reproducible installs (the same expected hash always resolves to the same applied content). **What this does NOT protect against**: a first-time install where the attacker controls the download path. The initial SHA the profile prints is computed over what was just fetched; a MITMed first download would produce a hash that matches the malicious payload, not the real upstream. For real trust pinning, verify the published commit SHA out-of-band (e.g. against `https://github.com/26zl/PowerShellPerfect/commits/main` in a browser) before running with an expected hash. +- PSReadLine history filters out lines containing: `password`, `secret`, `token`, `api[_-]?key`, `connectionstring`, `credential`, `bearer`, VirusTotal API key variable names, direct JWT values, and sensitive command forms such as `pwnd ` and `jwtd `. - Repository download URLs are centralized (not hardcoded inline) - When in CI or when `$env:AI_AGENT` is set, the profile and setup skip network calls and interactive prompts, reducing exposure in automated or AI/agent environments + +## Code-Execution Surfaces + +The profile has four mechanisms that execute code from places other than the main profile file. All are user-owned and opt-in, but the trust model should be explicit: + +- **`profile_user.ps1`** - dot-sourced every profile load. Lives in the user's `$PROFILE` directory. Equivalent to editing the profile itself. +- **`plugins/*.ps1`** - dot-sourced every profile load. Lives in `%LOCALAPPDATA%\PowerShellProfile\plugins\`. Each file is isolated so one crash doesn't break others, but a plugin has full profile trust. +- **`user-settings.json` `commandOverrides`** - values are passed to `[scriptblock]::Create()` and defined as functions. **Default off** - requires explicit opt-in via `features.commandOverrides = true` in the same file, so a silently-edited JSON file cannot redefine commands on next shell launch. When the feature is off but entries exist, the profile prints a notice at startup and ignores them. Do not copy `user-settings.json` from untrusted sources. +- **`.psprc.ps1` per-directory profiles** - auto-loaded on `cd` into a trusted directory only. Trust is explicit and per-directory via `Add-TrustedDirectory`; untrusted directories only print a warning. This is modeled after `direnv`. + +Because all four surfaces require local filesystem write access (or explicit `Add-TrustedDirectory` for `.psprc.ps1`), they are not exploitable by remote attackers without first achieving arbitrary file write. Treat user-settings.json and plugin files as carefully as any dotfile. diff --git a/setprofile.ps1 b/setprofile.ps1 index 000e744..d0336f9 100644 --- a/setprofile.ps1 +++ b/setprofile.ps1 @@ -15,7 +15,21 @@ foreach ($dir in $profileDirs) { if (!(Test-Path -Path $dir)) { New-Item -Path $dir -ItemType "directory" -Force | Out-Null } - Copy-Item (Join-Path $PSScriptRoot "Microsoft.PowerShell_profile.ps1") $dir + $targetProfile = Join-Path $dir "Microsoft.PowerShell_profile.ps1" + # Mirror setup.ps1 backup behaviour: timestamped + rolling 5 so repeated runs + # never clobber the original profile backup. + if (Test-Path -Path $targetProfile -PathType Leaf) { + $backupStamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $dir ("oldprofile.$backupStamp.ps1") + Copy-Item -Path $targetProfile -Destination $backupPath -Force + Write-Host " Backup saved to [$backupPath]" -ForegroundColor DarkGray + $oldBackups = Get-ChildItem -Path $dir -Filter 'oldprofile*.ps1' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 + foreach ($old in $oldBackups) { + Remove-Item $old.FullName -Force -ErrorAction SilentlyContinue + } + } + Copy-Item -Path (Join-Path $PSScriptRoot "Microsoft.PowerShell_profile.ps1") -Destination $targetProfile -Force Write-Host "Profile copied to $dir" -ForegroundColor Green } if ([Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT) { diff --git a/setup.ps1 b/setup.ps1 index 177f415..c2a4b6f 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -16,7 +16,17 @@ param( # Used by ci-functional.ps1 to test local changes without a GitHub round-trip. [string]$LocalRepo = '', - [switch]$CiMode + [switch]$CiMode, + + # Interactive wizard: asks user for OMP theme, color scheme, font, features, background. + # Auto-enabled when interactive + not in CI + not AI-agent. -SkipWizard forces defaults. + # -Resume continues from a prior incomplete wizard run (state in $env:TEMP\psp-wizard-state.json). + [switch]$Wizard, + [switch]$SkipWizard, + [switch]$Resume, + [ValidatePattern('^[A-Fa-f0-9]{64}$')] + [string]$ExpectedSha256, + [switch]$SkipHashCheck ) # Normalize agent detection (same as profile): if host set a known agent var, set AI_AGENT so we only check one name @@ -24,47 +34,812 @@ if (-not [bool]$env:AI_AGENT -and ([bool]$env:AGENT_ID -or [bool]$env:CLAUDE_COD $env:AI_AGENT = '1' } +# Raise the TLS floor to 1.2 on Windows PowerShell 5.1 before the first HTTPS request. On older .NET 4.x the +# process default can still be SSL3/TLS1.0, which github.com / raw.githubusercontent.com reject (downloads just +# fail) and which leaves a protocol-downgrade window. PowerShell 7 (Core) negotiates via the OS and needs none. +if ($PSVersionTable.PSVersion.Major -lt 6) { + try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { $null = $_ } +} + $RepoBase = "https://raw.githubusercontent.com/26zl/PowerShellPerfect/main" +$script:DownloadedProfilePath = $null +$script:DownloadedThemeConfigPath = $null +$script:DownloadedTerminalConfigPath = $null +$script:VerifiedInstallBundle = $false + +# Auto-detect local repo: when the script sits next to Microsoft.PowerShell_profile.ps1 and +# -LocalRepo was not supplied, prefer the local checkout over a GitHub round-trip. This makes +# `.\setup.ps1` work as expected from a manual clone and matches README's "manual setup" doc. +# Skipped when piped via `irm | iex` because $PSScriptRoot is empty in that mode. +if ([string]::IsNullOrWhiteSpace($LocalRepo) -and $PSScriptRoot -and + (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'Microsoft.PowerShell_profile.ps1')) -and + (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'theme.json')) -and + (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'terminal-config.json'))) { + $LocalRepo = $PSScriptRoot + Write-Host "Using local repo checkout: $LocalRepo" -ForegroundColor DarkGray +} + +function Get-CombinedSha256 { + param([Parameter(Mandatory)][string[]]$Parts) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString( + $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($Parts -join ':'))) + ).Replace('-', '') + } + finally { $sha.Dispose() } +} + +# Download helper with retry, size validation, and corrupt-file cleanup. +# Defined before remote bundle verification so setup can fail before any local mutations. +function Invoke-DownloadWithRetry { + param( + [Parameter(Mandatory)] + [string]$Uri, + [Parameter(Mandatory)] + [string]$OutFile, + [int]$TimeoutSec = 10, + [int]$MaxAttempts = 2, + [int]$BackoffSec = 2 + ) + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + Remove-Item $OutFile -Force -ErrorAction SilentlyContinue + Invoke-RestMethod -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + if (-not (Test-Path $OutFile) -or (Get-Item $OutFile).Length -eq 0) { + Remove-Item $OutFile -Force -ErrorAction SilentlyContinue + throw 'Downloaded file is missing or empty' + } + return + } + catch { + if ($attempt -lt $MaxAttempts) { + Write-Host " Download failed (attempt $attempt/$MaxAttempts): $_ Retrying in ${BackoffSec}s..." -ForegroundColor Yellow + Start-Sleep -Seconds $BackoffSec + } + else { + throw $_ + } + } + } +} + +function Remove-SafeTempDirectory { + param( + [AllowNull()][string]$Path, + [string]$NamePrefix = 'psp-' + ) + if ([string]::IsNullOrWhiteSpace($Path)) { return } + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { return } + $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + $rootFull = [System.IO.Path]::GetFullPath($resolved.ProviderPath) + $rootName = Split-Path -Path $rootFull -Leaf + if ($rootName -like "$NamePrefix*" -and $rootFull.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $rootFull -Recurse -Force -ErrorAction SilentlyContinue + } + else { + Write-Host " Skipped temp cleanup outside expected temp root: $rootFull" -ForegroundColor Yellow + } +} + +function Initialize-RemoteInstallBundle { + if ($LocalRepo -or $script:VerifiedInstallBundle) { return } + + $tempSuffix = [System.IO.Path]::GetRandomFileName() + $bundleProfile = Join-Path $env:TEMP "psp-setup-profile-$tempSuffix.ps1" + $bundleTheme = Join-Path $env:TEMP "psp-setup-theme-$tempSuffix.json" + $bundleTerminal = Join-Path $env:TEMP "psp-setup-terminal-$tempSuffix.json" + + try { + Invoke-DownloadWithRetry -Uri "$RepoBase/Microsoft.PowerShell_profile.ps1" -OutFile $bundleProfile -TimeoutSec 30 + Invoke-DownloadWithRetry -Uri "$RepoBase/theme.json" -OutFile $bundleTheme + Invoke-DownloadWithRetry -Uri "$RepoBase/terminal-config.json" -OutFile $bundleTerminal + + $profileHash = (Get-FileHash -LiteralPath $bundleProfile -Algorithm SHA256).Hash + $themeHash = (Get-FileHash -LiteralPath $bundleTheme -Algorithm SHA256).Hash + $terminalHash = (Get-FileHash -LiteralPath $bundleTerminal -Algorithm SHA256).Hash + $combinedHash = Get-CombinedSha256 -Parts @( + "profile:$profileHash" + "theme:$themeHash" + "terminal:$terminalHash" + ) + + if (-not $SkipHashCheck) { + if (-not $ExpectedSha256) { + Write-Host "Downloaded install bundle hashes:" -ForegroundColor Yellow + Write-Host " profile.ps1: $profileHash" -ForegroundColor Yellow + Write-Host " theme.json: $themeHash" -ForegroundColor Yellow + Write-Host " terminal-config: $terminalHash" -ForegroundColor Yellow + Write-Host " combined: $combinedHash" -ForegroundColor Yellow + throw "Hash input required. Re-run with -ExpectedSha256 '$combinedHash' or -SkipHashCheck." + } + + if ($combinedHash -ne $ExpectedSha256.ToUpperInvariant()) { + throw "Combined hash mismatch. Expected $($ExpectedSha256.ToUpperInvariant()), got $combinedHash." + } + } + + $script:DownloadedProfilePath = $bundleProfile + $script:DownloadedThemeConfigPath = $bundleTheme + $script:DownloadedTerminalConfigPath = $bundleTerminal + $script:VerifiedInstallBundle = $true + } + catch { + Remove-Item $bundleProfile, $bundleTheme, $bundleTerminal -Force -ErrorAction SilentlyContinue + throw + } +} + +function Test-IsTrustedRawGitHubUrl { + param([Parameter(Mandatory)][string]$Url) + try { $uri = [Uri]$Url } catch { return $false } + if ($uri.Scheme -ne 'https') { return $false } + return $uri.Host -in @('raw.githubusercontent.com', 'githubusercontent.com') +} + +function Resolve-SetupSourcePath { + param([Parameter(Mandatory)][ValidateSet('profile', 'theme', 'terminal')][string]$Kind) + if ($LocalRepo) { + switch ($Kind) { + 'profile' { return (Join-Path $LocalRepo 'Microsoft.PowerShell_profile.ps1') } + 'theme' { return (Join-Path $LocalRepo 'theme.json') } + 'terminal' { return (Join-Path $LocalRepo 'terminal-config.json') } + } + } + + Initialize-RemoteInstallBundle + switch ($Kind) { + 'profile' { return $script:DownloadedProfilePath } + 'theme' { return $script:DownloadedThemeConfigPath } + 'terminal' { return $script:DownloadedTerminalConfigPath } + } +} + +# Curated color scheme library (used by install wizard). +# Each entry is a full Windows Terminal scheme definition. Users who want more +# can paste their own into user-settings.json.windowsTerminal.scheme. +$script:CuratedSchemes = @( + @{ Name = 'Tokyo Night'; Desc = 'Cool blue-purple, balanced for long coding sessions (default)' + Scheme = @{ name = 'Tokyo Night'; background = '#1a1b26'; foreground = '#a9b1d6'; cursorColor = '#a9b1d6'; selectionBackground = '#33467c' + black = '#32344a'; red = '#f7768e'; green = '#9ece6a'; yellow = '#e0af68'; blue = '#7aa2f7'; purple = '#ad8ee6'; cyan = '#449dab'; white = '#787c99' + brightBlack = '#444b6a'; brightRed = '#ff7a93'; brightGreen = '#b9f27c'; brightYellow = '#ff9e64'; brightBlue = '#7da6ff'; brightPurple = '#bb9af7'; brightCyan = '#0db9d7'; brightWhite = '#acb0d0' } } + @{ Name = 'Gruvbox Dark'; Desc = 'Retro warm yellow/orange/red, low-contrast and easy on the eyes' + Scheme = @{ name = 'Gruvbox Dark'; background = '#282828'; foreground = '#ebdbb2'; cursorColor = '#ebdbb2'; selectionBackground = '#665c54' + black = '#282828'; red = '#cc241d'; green = '#98971a'; yellow = '#d79921'; blue = '#458588'; purple = '#b16286'; cyan = '#689d6a'; white = '#a89984' + brightBlack = '#928374'; brightRed = '#fb4934'; brightGreen = '#b8bb26'; brightYellow = '#fabd2f'; brightBlue = '#83a598'; brightPurple = '#d3869b'; brightCyan = '#8ec07c'; brightWhite = '#ebdbb2' } } + @{ Name = 'Dracula'; Desc = 'Dark purple with vibrant pink/green/cyan accents' + Scheme = @{ name = 'Dracula'; background = '#282a36'; foreground = '#f8f8f2'; cursorColor = '#f8f8f2'; selectionBackground = '#44475a' + black = '#21222c'; red = '#ff5555'; green = '#50fa7b'; yellow = '#f1fa8c'; blue = '#bd93f9'; purple = '#ff79c6'; cyan = '#8be9fd'; white = '#f8f8f2' + brightBlack = '#6272a4'; brightRed = '#ff6e6e'; brightGreen = '#69ff94'; brightYellow = '#ffffa5'; brightBlue = '#d6acff'; brightPurple = '#ff92df'; brightCyan = '#a4ffff'; brightWhite = '#ffffff' } } + @{ Name = 'Catppuccin Mocha'; Desc = 'Soft pastel dark; popular with modern dev community' + Scheme = @{ name = 'Catppuccin Mocha'; background = '#1e1e2e'; foreground = '#cdd6f4'; cursorColor = '#f5e0dc'; selectionBackground = '#585b70' + black = '#45475a'; red = '#f38ba8'; green = '#a6e3a1'; yellow = '#f9e2af'; blue = '#89b4fa'; purple = '#f5c2e7'; cyan = '#94e2d5'; white = '#bac2de' + brightBlack = '#585b70'; brightRed = '#f38ba8'; brightGreen = '#a6e3a1'; brightYellow = '#f9e2af'; brightBlue = '#89b4fa'; brightPurple = '#f5c2e7'; brightCyan = '#94e2d5'; brightWhite = '#a6adc8' } } + @{ Name = 'Nord'; Desc = 'Cool arctic blues and frosty whites, minimal contrast' + Scheme = @{ name = 'Nord'; background = '#2e3440'; foreground = '#d8dee9'; cursorColor = '#d8dee9'; selectionBackground = '#4c566a' + black = '#3b4252'; red = '#bf616a'; green = '#a3be8c'; yellow = '#ebcb8b'; blue = '#81a1c1'; purple = '#b48ead'; cyan = '#88c0d0'; white = '#e5e9f0' + brightBlack = '#4c566a'; brightRed = '#bf616a'; brightGreen = '#a3be8c'; brightYellow = '#ebcb8b'; brightBlue = '#81a1c1'; brightPurple = '#b48ead'; brightCyan = '#8fbcbb'; brightWhite = '#eceff4' } } + @{ Name = 'One Half Dark'; Desc = 'Atom-inspired, balanced mid-contrast' + Scheme = @{ name = 'One Half Dark'; background = '#282c34'; foreground = '#dcdfe4'; cursorColor = '#a3b3cc'; selectionBackground = '#474e5d' + black = '#282c34'; red = '#e06c75'; green = '#98c379'; yellow = '#e5c07b'; blue = '#61afef'; purple = '#c678dd'; cyan = '#56b6c2'; white = '#dcdfe4' + brightBlack = '#5d677a'; brightRed = '#e06c75'; brightGreen = '#98c379'; brightYellow = '#e5c07b'; brightBlue = '#61afef'; brightPurple = '#c678dd'; brightCyan = '#56b6c2'; brightWhite = '#dcdfe4' } } + @{ Name = 'Solarized Dark'; Desc = 'Ethan Schoonover classic, low eye strain' + Scheme = @{ name = 'Solarized Dark'; background = '#002b36'; foreground = '#839496'; cursorColor = '#93a1a1'; selectionBackground = '#073642' + black = '#073642'; red = '#dc322f'; green = '#859900'; yellow = '#b58900'; blue = '#268bd2'; purple = '#d33682'; cyan = '#2aa198'; white = '#eee8d5' + brightBlack = '#002b36'; brightRed = '#cb4b16'; brightGreen = '#586e75'; brightYellow = '#657b83'; brightBlue = '#839496'; brightPurple = '#6c71c4'; brightCyan = '#93a1a1'; brightWhite = '#fdf6e3' } } +) + +# Curated Nerd Fonts (name = ryanoasis release asset name without .zip). +# DisplayName is what appears in Windows after install, used for WT "face" setting. +$script:CuratedFonts = @( + @{ Asset = 'CascadiaCode'; DisplayName = 'CaskaydiaCove NF'; Desc = 'Microsoft Cascadia + icons (default)' } + @{ Asset = 'JetBrainsMono'; DisplayName = 'JetBrainsMono NF'; Desc = 'JetBrains flagship, tight + readable' } + @{ Asset = 'FiraCode'; DisplayName = 'FiraCode NF'; Desc = 'Popular ligature font' } + @{ Asset = 'Meslo'; DisplayName = 'MesloLGM NF'; Desc = 'p10k default, excellent rendering' } + @{ Asset = 'Hack'; DisplayName = 'Hack NF'; Desc = 'Simple + workhorse' } + @{ Asset = 'Iosevka'; DisplayName = 'Iosevka NF'; Desc = 'Narrow monospace, space-efficient' } +) + +# Install wizard (setup.ps1 -Wizard). Guarded so CI/non-interactive hosts skip. +# Internal: show a numbered pick list from stdin/Out-GridView/fzf. Returns the picked +# item (or the user's -Default if they press Enter/skip). Multi-select via fzf --multi +# not supported here; each wizard step picks one item. +function Select-WizardItem { + param( + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][array]$Items, # array of hashtables with .Name and .Desc + [string]$DefaultName, # pre-selected (Enter to accept) + [switch]$AllowSkip # Enter with no default = skip + ) + Write-Host '' + Write-Host ("-- {0} --" -f $Title) -ForegroundColor Cyan + for ($i = 0; $i -lt $Items.Count; $i++) { + $marker = if ($DefaultName -and $Items[$i].Name -eq $DefaultName) { '>' } else { ' ' } + $desc = if ($Items[$i].Desc) { " - $($Items[$i].Desc)" } else { '' } + Write-Host (" {0} [{1,2}] {2}{3}" -f $marker, ($i + 1), $Items[$i].Name, $desc) + } + $defaultHint = if ($DefaultName) { " (Enter = $DefaultName)" } elseif ($AllowSkip) { ' (Enter = skip)' } else { '' } + $prompt = "Pick 1-$($Items.Count)$defaultHint" + do { + $raw = Read-Host $prompt + if ([string]::IsNullOrWhiteSpace($raw)) { + if ($DefaultName) { return $Items | Where-Object { $_.Name -eq $DefaultName } | Select-Object -First 1 } + if ($AllowSkip) { return $null } + continue + } + $n = 0 + if ([int]::TryParse($raw, [ref]$n) -and $n -ge 1 -and $n -le $Items.Count) { + return $Items[$n - 1] + } + # Allow fuzzy name match too + $match = $Items | Where-Object { $_.Name -like "*$raw*" } | Select-Object -First 1 + if ($match) { return $match } + Write-Host " Invalid; try again." -ForegroundColor Yellow + } while ($true) +} + +# Internal: fetch latest Nerd Fonts release tag (e.g. 'v3.2.1' -> '3.2.1'). +# Fallback to the version in terminal-config.json, then a hardcoded fallback. +function Get-LatestNerdFontVersion { + try { + $rel = Invoke-RestMethod -Uri 'https://api.github.com/repos/ryanoasis/nerd-fonts/releases/latest' ` + -TimeoutSec 15 -UseBasicParsing -Headers @{ 'User-Agent' = 'PowerShellPerfect-setup' } -ErrorAction Stop + if ($rel.tag_name -match 'v?(\d+\.\d+\.\d+)') { return $matches[1] } + } + catch { $null = $_ } + return '3.2.1' +} + +# Internal: fetch the upstream OMP theme list from GitHub API. Returns array of +# @{ Name='atomic'; Url='https://...atomic.omp.json' }. Empty array on network failure. +function Get-OmpThemeList { + $apiUrl = 'https://api.github.com/repos/JanDeDobbeleer/oh-my-posh/contents/themes?ref=main' + try { + $resp = Invoke-RestMethod -Uri $apiUrl -TimeoutSec 15 -UseBasicParsing -Headers @{ 'User-Agent' = 'PowerShellPerfect-setup' } -ErrorAction Stop + } + catch { return @() } + $themes = @() + foreach ($item in $resp) { + if ($item.name -like '*.omp.json') { + $baseName = $item.name -replace '\.omp\.json$', '' + $themes += @{ Name = $baseName; Desc = ''; Url = $item.download_url } + } + } + return $themes | Sort-Object { $_.Name } +} + +# Internal: write all wizard choices to user-settings.json via Merge-JsonObject. +# Overwrites in place so re-running the wizard applies the new set without piling +# up stale overrides. Choices is a hashtable with keys: Theme, Scheme, Font, Features, +# Background, TabBar. Any $null key is skipped (user chose 'keep current'). +function Save-WizardChoices { + param( + [Parameter(Mandatory)][hashtable]$Choices, + [Parameter(Mandatory)][string]$UserSettingsPath + ) + $utf8 = [System.Text.UTF8Encoding]::new($false) + $s = if (Test-Path $UserSettingsPath) { + try { (Get-Content $UserSettingsPath -Raw | ConvertFrom-Json -ErrorAction Stop) } + catch { [PSCustomObject]@{} } + } + else { [PSCustomObject]@{} } + if ($null -eq $s) { $s = [PSCustomObject]@{} } + + # theme (OMP) + if ($Choices.Theme) { + $s | Add-Member -NotePropertyName 'theme' -NotePropertyValue ([PSCustomObject]@{ name = $Choices.Theme.Name; url = $Choices.Theme.Url }) -Force + } + # windowsTerminal.colorScheme + scheme (color scheme) + if ($Choices.Scheme) { + if (-not $s.PSObject.Properties['windowsTerminal']) { $s | Add-Member -NotePropertyName 'windowsTerminal' -NotePropertyValue ([PSCustomObject]@{}) -Force } + $s.windowsTerminal | Add-Member -NotePropertyName 'colorScheme' -NotePropertyValue $Choices.Scheme.name -Force + $s.windowsTerminal | Add-Member -NotePropertyName 'scheme' -NotePropertyValue ([PSCustomObject]$Choices.Scheme) -Force + } + # windowsTerminal.theme + themeDefinition (tab-bar + application chrome theme) + if ($Choices.TabBar) { + if (-not $s.PSObject.Properties['windowsTerminal']) { $s | Add-Member -NotePropertyName 'windowsTerminal' -NotePropertyValue ([PSCustomObject]@{}) -Force } + $appTheme = if ($Choices.AppTheme) { $Choices.AppTheme } else { 'dark' } + $td = [PSCustomObject]@{ + name = 'PSP.WizardTabs' + tab = [PSCustomObject]@{ background = $Choices.TabBar; unfocusedBackground = $Choices.TabBar } + tabRow = [PSCustomObject]@{ background = $Choices.TabBar; unfocusedBackground = $Choices.TabBar } + window = [PSCustomObject]@{ applicationTheme = $appTheme } + } + $s.windowsTerminal | Add-Member -NotePropertyName 'theme' -NotePropertyValue 'PSP.WizardTabs' -Force + $s.windowsTerminal | Add-Member -NotePropertyName 'themeDefinition' -NotePropertyValue $td -Force + } + # defaults (font + background + terminal appearance) + if ($Choices.Font -or $Choices.Background -or $Choices.Terminal) { + if (-not $s.PSObject.Properties['defaults']) { $s | Add-Member -NotePropertyName 'defaults' -NotePropertyValue ([PSCustomObject]@{}) -Force } + } + if ($Choices.Font) { + $fontObj = if ($s.defaults.PSObject.Properties['font']) { $s.defaults.font } else { [PSCustomObject]@{} } + $fontObj | Add-Member -NotePropertyName 'face' -NotePropertyValue $Choices.Font.DisplayName -Force + $s.defaults | Add-Member -NotePropertyName 'font' -NotePropertyValue $fontObj -Force + } + if ($Choices.Background -and $Choices.Background.Path) { + $s.defaults | Add-Member -NotePropertyName 'backgroundImage' -NotePropertyValue $Choices.Background.Path -Force + $s.defaults | Add-Member -NotePropertyName 'backgroundImageOpacity' -NotePropertyValue $Choices.Background.Opacity -Force + $s.defaults | Add-Member -NotePropertyName 'backgroundImageStretchMode' -NotePropertyValue 'uniformToFill' -Force + $s.defaults | Add-Member -NotePropertyName 'backgroundImageAlignment' -NotePropertyValue 'center' -Force + } + # Terminal appearance: opacity, useAcrylic, cursorShape, padding, scrollbarState, historySize + # go straight into defaults; fontSize is nested under defaults.font.size so the wizard's + # face pick (if any) is preserved. + if ($Choices.Terminal) { + foreach ($k in $Choices.Terminal.Keys) { + $v = $Choices.Terminal[$k] + if ($k -eq 'fontSize') { + $fontObj = if ($s.defaults.PSObject.Properties['font']) { $s.defaults.font } else { [PSCustomObject]@{} } + $fontObj | Add-Member -NotePropertyName 'size' -NotePropertyValue $v -Force + $s.defaults | Add-Member -NotePropertyName 'font' -NotePropertyValue $fontObj -Force + } + else { + $s.defaults | Add-Member -NotePropertyName $k -NotePropertyValue $v -Force + } + } + } + # features + if ($Choices.Features) { + if (-not $s.PSObject.Properties['features']) { $s | Add-Member -NotePropertyName 'features' -NotePropertyValue ([PSCustomObject]@{}) -Force } + foreach ($k in $Choices.Features.Keys) { + $s.features | Add-Member -NotePropertyName $k -NotePropertyValue $Choices.Features[$k] -Force + } + } + # PSReadLine: when 'scheme', derive a syntax palette from the chosen color scheme so the + # shell reflects the picked theme without asking 10 hex questions. Profile reads + # user-settings.json.psreadline.colors on top of theme.json's palette. + if ($Choices.PSReadLine -eq 'scheme' -and $Choices.Scheme) { + $sc = $Choices.Scheme + $rl = [PSCustomObject]@{ + Command = $sc.brightCyan + Parameter = $sc.cyan + Operator = $sc.yellow + Variable = $sc.foreground + String = $sc.green + Number = $sc.brightBlue + Type = $sc.brightGreen + Comment = $sc.brightBlack + Keyword = $sc.purple + Error = $sc.red + } + if (-not $s.PSObject.Properties['psreadline']) { $s | Add-Member -NotePropertyName 'psreadline' -NotePropertyValue ([PSCustomObject]@{}) -Force } + $s.psreadline | Add-Member -NotePropertyName 'colors' -NotePropertyValue $rl -Force + } + $json = $s | ConvertTo-Json -Depth 20 + [System.IO.File]::WriteAllText($UserSettingsPath, $json, $utf8) +} + +# Yes/no prompt with default (Enter = default). Returns [bool]. +function Read-WizardYesNo { + param([Parameter(Mandatory)][string]$Prompt, [bool]$Default = $true) + $hint = if ($Default) { '[Y/n]' } else { '[y/N]' } + $raw = Read-Host "$Prompt $hint" + if ([string]::IsNullOrWhiteSpace($raw)) { return $Default } + return ($raw -match '^(?i:y|yes)$') +} + +# Main wizard. Returns hashtable of choices; caller writes them via Save-WizardChoices. +function Start-InstallWizard { + param([string]$StatePath) + + $choices = @{ + Theme = $null # @{ Name='atomic'; Url='...' } + Scheme = $null # full scheme hashtable + Font = $null # curated font entry + Features = $null # hashtable + Background = $null # @{ Path='...'; Opacity=0.15 } + TabBar = $null # hex string + AppTheme = $null # 'dark' | 'light' - WT window.applicationTheme + Terminal = $null # ordered hashtable: opacity, fontSize, useAcrylic, cursorShape, padding, scrollbarState, historySize + PSReadLine = $null # 'default' | 'scheme' - scheme derives from chosen color scheme + Editor = $null # cmd name (code, nvim, notepad, ...) - moved in from setup.ps1 [2/10] + TelemetryOptOut = $null # $true = set POWERSHELL_TELEMETRY_OPTOUT machine-wide + CompletedSteps = @() + } + + # Bump when the $choices shape changes (new fields, renamed keys, different step order). + # Resume loads from state only when the stored version matches; mismatches start fresh so + # stale state from an older setup.ps1 can't apply ghost fields to current logic. + $WIZARD_STATE_SCHEMA = 2 + + # Resume from state file if caller passed one that exists + if ($StatePath -and (Test-Path $StatePath)) { + try { + $prev = Get-Content $StatePath -Raw | ConvertFrom-Json + $prevSchema = if ($prev.PSObject.Properties['schemaVersion']) { [int]$prev.schemaVersion } else { 1 } + if ($prevSchema -ne $WIZARD_STATE_SCHEMA) { + Write-Host '' + Write-Host ("Wizard state schema mismatch (found v{0}, expected v{1}); starting fresh." -f $prevSchema, $WIZARD_STATE_SCHEMA) -ForegroundColor Yellow + Remove-Item $StatePath -Force -ErrorAction SilentlyContinue + } + else { + # Concurrency check: if the state file was written by a different, still-live + # setup.ps1 process, two wizards are racing for the same state file. Warn the + # user before we let this invocation stomp on the other one's progress. + $otherPid = if ($prev.PSObject.Properties['ownerPid']) { [int]$prev.ownerPid } else { 0 } + $otherAlive = $false + if ($otherPid -gt 0 -and $otherPid -ne $PID) { + try { + $otherProc = Get-Process -Id $otherPid -ErrorAction Stop + if ($otherProc -and $otherProc.ProcessName -match '^(pwsh|powershell)$') { $otherAlive = $true } + } + catch { $otherAlive = $false } + } + if ($otherAlive) { + Write-Host '' + Write-Host ("Another setup.ps1 wizard (PID {0}) appears to be running with this state file." -f $otherPid) -ForegroundColor Yellow + if (-not (Read-WizardYesNo -Prompt ' Take over anyway? (the other run will silently overwrite on its next save)' -Default $false)) { + throw 'WizardCancelled: concurrent wizard detected.' + } + } + Write-Host '' + Write-Host ("Found wizard state from {0}." -f $prev.Timestamp) -ForegroundColor Yellow + if (Read-WizardYesNo -Prompt 'Resume?' -Default $true) { + foreach ($prop in $prev.Choices.PSObject.Properties) { + $choices[$prop.Name] = $prop.Value + } + # ConvertFrom-Json turns the [ordered]@{} Terminal/Features back into PSCustomObjects, which + # have no .Keys and don't support [$key] indexing. Save-WizardChoices (line ~364/379) and the + # summary (line ~736/747) iterate .Keys, so without this rehydration every terminal-appearance + # and feature-toggle choice would be silently dropped on resume. Rebuild them as ordered + # hashtables so the rest of the wizard sees the shape it originally wrote. + foreach ($field in 'Terminal', 'Features') { + if ($choices[$field] -is [System.Management.Automation.PSCustomObject]) { + $ht = [ordered]@{} + foreach ($p in $choices[$field].PSObject.Properties) { $ht[$p.Name] = $p.Value } + $choices[$field] = $ht + } + } + Write-Host ("Resuming from step after: {0}" -f ($choices.CompletedSteps -join ', ')) -ForegroundColor DarkGray + } + else { Remove-Item $StatePath -Force -ErrorAction SilentlyContinue } + } + } + catch { + if ($_.Exception.Message -like 'WizardCancelled*') { throw } + Remove-Item $StatePath -Force -ErrorAction SilentlyContinue + } + } + + function Save-State { + if (-not $StatePath) { return } + $snap = @{ schemaVersion = $WIZARD_STATE_SCHEMA; Timestamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'); ownerPid = $PID; Choices = $choices } + $json = $snap | ConvertTo-Json -Depth 20 + [System.IO.File]::WriteAllText($StatePath, $json, [System.Text.UTF8Encoding]::new($false)) + } + + Write-Host '' + Write-Host '=========================================' -ForegroundColor Magenta + Write-Host ' PowerShellPerfect Install Wizard' -ForegroundColor Magenta + Write-Host '=========================================' -ForegroundColor Magenta + Write-Host ' Pick your cosmetics. All 130+ commands and extensibility APIs ship regardless.' -ForegroundColor DarkGray + Write-Host ' Press Enter at any prompt to accept default / skip that step.' -ForegroundColor DarkGray + + # STEP 0: Quick start shortcut - offers a "just make it nice" preset that fills all 10 + # steps with sensible defaults and skips straight to the summary. Users who want to + # customize say No and get the full wizard. Skipped when resuming (choices already loaded). + if ($choices.CompletedSteps.Count -eq 0) { + Write-Host '' + Write-Host '-- Quick start --' -ForegroundColor Cyan + Write-Host ' Preset: Tokyo Night scheme, CascadiaCode Nerd Font, VS Code editor,' -ForegroundColor DarkGray + Write-Host ' scheme-derived PSReadLine colors, dark chrome, default features.' -ForegroundColor DarkGray + if (Read-WizardYesNo -Prompt ' Use quick-start defaults and skip the 10 steps?' -Default $false) { + $tokyoNight = $script:CuratedSchemes | Where-Object { $_.Name -eq 'Tokyo Night' } | Select-Object -First 1 + $cascadia = $script:CuratedFonts | Where-Object { $_.DisplayName -match 'Cascadia' } | Select-Object -First 1 + $choices.Theme = @{ Name = 'atomic'; Url = 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/atomic.omp.json' } + if ($tokyoNight) { $choices.Scheme = $tokyoNight.Scheme } + if ($cascadia) { $choices.Font = $cascadia } + $choices.TabBar = if ($tokyoNight) { $tokyoNight.Scheme.background } else { '#1a1b26' } + $choices.AppTheme = 'dark' + $choices.Terminal = $null # keep terminal-config.json defaults + $choices.PSReadLine = 'scheme' + $choices.Background = $null + $choices.Editor = 'code' + $choices.TelemetryOptOut = $false + $choices.Features = [ordered]@{ psfzf = $true; predictions = $true; startupMessage = $true; perDirProfiles = $true; commandOverrides = $false } + $choices.CompletedSteps = @('Theme', 'Scheme', 'Font', 'TabBar', 'Terminal', 'PSReadLine', 'Background', 'Editor', 'Telemetry', 'Features') + Save-State + Write-Host ' Quick-start preset applied. Jumping to summary.' -ForegroundColor Green + } + } + + # STEP 1: OMP theme + if ('Theme' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '[1/10] Fetching Oh My Posh theme catalog...' -ForegroundColor Cyan + $themes = Get-OmpThemeList + if ($themes.Count -eq 0) { + Write-Host ' (network failed or empty; keeping default "pure")' -ForegroundColor Yellow + $choices.Theme = @{ Name = 'pure'; Url = 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/pure.omp.json' } + } + else { + Write-Host (" {0} themes available. Type number, partial name, or Enter for 'pure' default." -f $themes.Count) -ForegroundColor DarkGray + $pick = Select-WizardItem -Title 'Oh My Posh theme' -Items $themes -DefaultName 'pure' + if ($pick) { $choices.Theme = $pick } + } + $choices.CompletedSteps += 'Theme' + Save-State + } + + # STEP 2: color scheme + if ('Scheme' -notin $choices.CompletedSteps) { + $pick = Select-WizardItem -Title 'Windows Terminal color scheme' -Items $script:CuratedSchemes -DefaultName 'Tokyo Night' + if ($pick) { $choices.Scheme = $pick.Scheme } + $choices.CompletedSteps += 'Scheme' + Save-State + } + + # STEP 3: Nerd Font + if ('Font' -notin $choices.CompletedSteps) { + $pick = Select-WizardItem -Title 'Nerd Font variant' -Items $script:CuratedFonts -DefaultName 'CascadiaCode' + if ($pick) { $choices.Font = $pick } + $choices.CompletedSteps += 'Font' + Save-State + } + + # STEP 4: tab-bar color + if ('TabBar' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '-- Tab bar color --' -ForegroundColor Cyan + Write-Host ' The strip at the top where tabs live. Default matches chosen color scheme background.' + $schemeBg = if ($choices.Scheme) { $choices.Scheme.background } else { '#1a1b26' } + $tabPresets = @( + @{ Name = "Scheme match ($schemeBg)"; Desc = 'Seamless - tab bar same as terminal background'; Value = $schemeBg } + @{ Name = 'Pure black (#000000)'; Desc = 'Maximum contrast'; Value = '#000000' } + @{ Name = 'Warm brown (#2a221d)'; Desc = 'Muted dark'; Value = '#2a221d' } + @{ Name = 'Custom hex'; Desc = 'Type your own #rrggbb'; Value = 'custom' } + @{ Name = 'Skip'; Desc = 'Leave WT default'; Value = $null } + ) + $pick = Select-WizardItem -Title 'Tab bar color' -Items $tabPresets -DefaultName "Scheme match ($schemeBg)" + if ($pick -and $pick.Value -eq 'custom') { + $hex = Read-Host ' Hex (e.g. #1e1e2e)' + if ($hex -match '^#[0-9A-Fa-f]{6}$') { $choices.TabBar = $hex } + } + elseif ($pick -and $pick.Value) { $choices.TabBar = $pick.Value } + + # WT application theme controls window chrome (title bar, rounded corners) when a + # custom themeDefinition is applied. 'dark' matches the curated color schemes; 'light' + # inverts chrome for users on light system themes. + $wantLight = Read-WizardYesNo -Prompt ' Use light window chrome (title bar, borders)?' -Default $false + $choices.AppTheme = if ($wantLight) { 'light' } else { 'dark' } + + $choices.CompletedSteps += 'TabBar' + Save-State + } + + # STEP 5: Terminal appearance (opacity, font size, cursor, padding, scrollbar, history). + # Each prompt accepts Enter = keep default (nothing is written for that field, so the + # terminal-config.json default still wins). Invalid values are rejected silently. + if ('Terminal' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '-- Terminal appearance --' -ForegroundColor Cyan + Write-Host ' Enter to keep current default for each field.' -ForegroundColor DarkGray + $term = [ordered]@{} + + $opRaw = Read-Host ' Opacity 1-100 (default 75)' + $opInt = 0 + if ($opRaw -and [int]::TryParse($opRaw, [ref]$opInt) -and $opInt -ge 1 -and $opInt -le 100) { + $term['opacity'] = $opInt + } + + $acrylicPrompt = Read-WizardYesNo -Prompt ' Use acrylic (translucent blur behind terminal)?' -Default $false + if ($acrylicPrompt) { $term['useAcrylic'] = $true } + + $fsRaw = Read-Host ' Font size 8-24 (default 11)' + $fsInt = 0 + if ($fsRaw -and [int]::TryParse($fsRaw, [ref]$fsInt) -and $fsInt -ge 8 -and $fsInt -le 24) { + $term['fontSize'] = $fsInt + } + + Write-Host ' Cursor shape: 1=bar 2=block 3=vintage 4=emptyBox 5=filledBox 6=doubleUnderscore' -ForegroundColor DarkGray + $csRaw = Read-Host ' Cursor shape (default bar)' + $csMap = @{ '1' = 'bar'; '2' = 'block'; '3' = 'vintage'; '4' = 'emptyBox'; '5' = 'filledBox'; '6' = 'doubleUnderscore' } + if ($csRaw -and $csMap.ContainsKey($csRaw)) { $term['cursorShape'] = $csMap[$csRaw] } + elseif ($csRaw -and ($csMap.Values -contains $csRaw)) { $term['cursorShape'] = $csRaw } + + $padRaw = Read-Host ' Cell padding in pixels 0-50 (default 8)' + $padInt = 0 + if ($padRaw -and [int]::TryParse($padRaw, [ref]$padInt) -and $padInt -ge 0 -and $padInt -le 50) { + $term['padding'] = "$padInt, $padInt, $padInt, $padInt" + } + + Write-Host ' Scrollbar: 1=visible 2=hidden 3=always' -ForegroundColor DarkGray + $sbRaw = Read-Host ' Scrollbar (default visible)' + $sbMap = @{ '1' = 'visible'; '2' = 'hidden'; '3' = 'always' } + if ($sbRaw -and $sbMap.ContainsKey($sbRaw)) { $term['scrollbarState'] = $sbMap[$sbRaw] } + elseif ($sbRaw -and ($sbMap.Values -contains $sbRaw)) { $term['scrollbarState'] = $sbRaw } + + $hsRaw = Read-Host ' History size 100-1000000 (default 20000)' + $hsInt = 0 + if ($hsRaw -and [int]::TryParse($hsRaw, [ref]$hsInt) -and $hsInt -ge 100 -and $hsInt -le 1000000) { + $term['historySize'] = $hsInt + } + + if ($term.Count -gt 0) { $choices.Terminal = $term } + $choices.CompletedSteps += 'Terminal' + Save-State + } + + # STEP 6: PSReadLine syntax colors. Three options: + # 1) Keep theme.json default (ship-time palette) + # 2) Derive from chosen WT color scheme (maps scheme roles to PSReadLine roles) + # 3) Skip - user edits user-settings.json.psreadline.colors manually later + if ('PSReadLine' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '-- PSReadLine syntax colors --' -ForegroundColor Cyan + $rlOptions = @( + @{ Name = 'theme.json default'; Desc = 'Keep the shipped palette'; Value = 'default' } + @{ Name = 'Derive from color scheme'; Desc = 'Map scheme ANSI roles to syntax (Command, String, ...)'; Value = 'scheme' } + @{ Name = 'Skip'; Desc = 'No override; edit user-settings.json later'; Value = $null } + ) + $pick = Select-WizardItem -Title 'PSReadLine colors' -Items $rlOptions -DefaultName 'theme.json default' + if ($pick -and $pick.Value) { $choices.PSReadLine = $pick.Value } + $choices.CompletedSteps += 'PSReadLine' + Save-State + } + + # STEP 7: background image + if ('Background' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '-- Background image (optional) --' -ForegroundColor Cyan + if (Read-WizardYesNo -Prompt 'Set a background image?' -Default $false) { + $bgPath = Read-Host ' Path to image (png/jpg/gif)' + if ($bgPath -and (Test-Path -LiteralPath $bgPath)) { + $opRaw = Read-Host ' Opacity 0.05-0.50 (Enter = 0.10)' + $op = 0.10 + $tmp = 0.0 + if ($opRaw -and [double]::TryParse($opRaw, [ref]$tmp) -and $tmp -ge 0.05 -and $tmp -le 0.50) { $op = $tmp } + $choices.Background = @{ Path = (Resolve-Path $bgPath).ProviderPath; Opacity = $op } + } + else { Write-Host ' (no file found; skipping)' -ForegroundColor Yellow } + } + $choices.CompletedSteps += 'Background' + Save-State + } + + # STEP 8: Editor preference (was setup.ps1 [2/10] - moved into the wizard so all + # interactive choices are in one place). The outer [2/10] step reads $choices.Editor + # and only prompts on its own when the wizard was skipped. + if ('Editor' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '-- Preferred editor --' -ForegroundColor Cyan + $choices.Editor = Select-PreferredEditor + $choices.CompletedSteps += 'Editor' + Save-State + } + + # STEP 9: Telemetry opt-out (was end-of-setup prompt - moved into the wizard). + # Only ask if the env var is not already set so repeat runs do not nag. + if ('Telemetry' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '-- PowerShell telemetry --' -ForegroundColor Cyan + if ([System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine')) { + Write-Host ' POWERSHELL_TELEMETRY_OPTOUT is already set. No change.' -ForegroundColor DarkGray + $choices.TelemetryOptOut = $false + } + else { + $choices.TelemetryOptOut = Read-WizardYesNo -Prompt ' Opt out of PowerShell telemetry? Sets POWERSHELL_TELEMETRY_OPTOUT=true machine-wide' -Default $false + } + $choices.CompletedSteps += 'Telemetry' + Save-State + } + + # STEP 10: feature toggles + if ('Features' -notin $choices.CompletedSteps) { + Write-Host '' + Write-Host '-- Profile feature toggles --' -ForegroundColor Cyan + $features = [ordered]@{} + $features['psfzf'] = Read-WizardYesNo -Prompt ' PSFzf fuzzy search (Ctrl+R history, Ctrl+T files)?' -Default $true + $features['predictions'] = Read-WizardYesNo -Prompt ' PSReadLine predictions (autocomplete suggestions)?' -Default $true + $features['startupMessage'] = Read-WizardYesNo -Prompt ' Show "Profile loaded in Xms" at startup?' -Default $true + $features['perDirProfiles'] = Read-WizardYesNo -Prompt ' Auto-load .psprc.ps1 on cd into trusted dirs?' -Default $true + $features['commandOverrides'] = Read-WizardYesNo -Prompt ' Allow user-settings.json commandOverrides (JSON -> scriptblock)?' -Default $false + $choices.Features = $features + $choices.CompletedSteps += 'Features' + Save-State + } + + # SUMMARY + confirm + Write-Host '' + Write-Host '=========================================' -ForegroundColor Magenta + Write-Host ' Summary of your choices' -ForegroundColor Magenta + Write-Host '=========================================' -ForegroundColor Magenta + Write-Host (" OMP theme: {0}" -f $(if ($choices.Theme) { $choices.Theme.Name } else { '(default)' })) + Write-Host (" Color scheme: {0}" -f $(if ($choices.Scheme) { $choices.Scheme.name } else { '(default)' })) + Write-Host (" Nerd Font: {0}" -f $(if ($choices.Font) { $choices.Font.DisplayName } else { '(default)' })) + Write-Host (" Tab bar: {0}" -f $(if ($choices.TabBar) { "$($choices.TabBar) ($($choices.AppTheme) chrome)" } else { '(WT default)' })) + Write-Host ' Terminal:' + if ($choices.Terminal) { + foreach ($k in $choices.Terminal.Keys) { + Write-Host (" {0,-16} = {1}" -f $k, $choices.Terminal[$k]) + } + } + else { Write-Host ' (all defaults)' -ForegroundColor DarkGray } + Write-Host (" PSReadLine: {0}" -f $(if ($choices.PSReadLine) { $choices.PSReadLine } else { '(theme.json default)' })) + Write-Host (" Background: {0}" -f $(if ($choices.Background) { "$($choices.Background.Path) @ $($choices.Background.Opacity)" } else { '(none)' })) + Write-Host (" Editor: {0}" -f $(if ($choices.Editor) { $choices.Editor } else { '(prompted outside wizard)' })) + Write-Host (" Telemetry: {0}" -f $(if ($choices.TelemetryOptOut) { 'opt out' } else { 'keep (no change)' })) + Write-Host ' Features:' + if ($choices.Features) { + foreach ($k in $choices.Features.Keys) { + Write-Host (" {0,-18} = {1}" -f $k, $choices.Features[$k]) + } + } + Write-Host '' + if (-not (Read-WizardYesNo -Prompt 'Apply all choices?' -Default $true)) { + Write-Host 'Wizard cancelled. Re-run setup.ps1 -Wizard to start over, or -Resume to continue.' -ForegroundColor Yellow + throw 'WizardCancelled' + } + + # Clean up state file on success + if ($StatePath -and (Test-Path $StatePath)) { Remove-Item $StatePath -Force -ErrorAction SilentlyContinue } + return $choices +} $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") $isCiHost = $CiMode -or [bool]$env:GITHUB_ACTIONS -or [bool]$env:CI # Ensure the script can run with elevated privileges for local installs. # In CI/non-admin mode we continue and skip admin-only steps instead of exiting. +# Hard-failure paths use `exit 1` (when run as a file) so callers and CI see a non-zero +# exit code. In `irm | iex` mode $PSCommandPath is empty, so we fall back to `return` +# to avoid terminating the user's shell. if (-not $isElevated -and -not $isCiHost) { Write-Host "Please run this script as an Administrator!" -ForegroundColor Red - return + if ($PSCommandPath) { exit 1 } else { return } } elseif (-not $isElevated -and $isCiHost) { Write-Host "Running setup.ps1 in CI/non-admin mode. Admin-only steps (LocalMachine execution policy, system-wide font install) will be skipped." -ForegroundColor Yellow } -# Set execution policy so the profile can load on future sessions +# For remote installs, verify/download the profile bundle before any local mutation +# (execution policy prompts, wizard writes, tool installs, profile copy, WT changes). +if (-not $LocalRepo) { + try { + Initialize-RemoteInstallBundle + } + catch { + Write-Host "Remote install bundle was not applied: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "No local setup changes were made before this verification step." -ForegroundColor Yellow + if ($PSCommandPath) { exit 1 } else { return } + } +} + +# ExecutionPolicy is security-sensitive. setup.ps1 must never silently relax it. +# We only surface guidance; users can opt in manually if their environment requires it. $currentUserPolicy = Get-ExecutionPolicy -Scope CurrentUser -if ($currentUserPolicy -in @('Restricted', 'AllSigned', 'Undefined')) { - Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force - Write-Host "Execution policy set to RemoteSigned for CurrentUser." -ForegroundColor Green +if ($currentUserPolicy -eq 'AllSigned') { + $canPromptPolicy = [Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT + if ($canPromptPolicy) { try { $null = [Console]::KeyAvailable } catch { $canPromptPolicy = $false } } + if ($canPromptPolicy) { + Write-Host "CurrentUser execution policy is 'AllSigned' (stricter than RemoteSigned)." -ForegroundColor Yellow + $reply = Read-Host " Downgrade to RemoteSigned so the profile can load unsigned scripts? [y/N]" + if ($reply -match '^[Yy]') { + Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force + Write-Host "Execution policy set to RemoteSigned for CurrentUser." -ForegroundColor Green + } + else { + Write-Host " Kept AllSigned. Profile will not load unless all .ps1 files are signed." -ForegroundColor Yellow + } + } + else { + Write-Host " CurrentUser policy is AllSigned. Skipping downgrade prompt (non-interactive)." -ForegroundColor Yellow + } +} +elseif ($currentUserPolicy -in @('Restricted', 'Undefined')) { + Write-Host "CurrentUser execution policy is '$currentUserPolicy'." -ForegroundColor Yellow + Write-Host " If the installed profile is blocked, opt in manually with:" -ForegroundColor DarkYellow + Write-Host " Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" -ForegroundColor DarkYellow } # Offer LocalMachine scope (covers all users and both PS editions) but don't force it. # In CI/non-admin mode we skip this prompt entirely. if (-not $isCiHost) { $machinePolicy = Get-ExecutionPolicy -Scope LocalMachine if ($machinePolicy -in @('Restricted', 'AllSigned', 'Undefined')) { - $canPrompt = [Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT - if ($canPrompt) { try { $null = [Console]::KeyAvailable } catch { $canPrompt = $false } } - if ($canPrompt) { - $reply = Read-Host " LocalMachine execution policy is '$machinePolicy'. Set to RemoteSigned for all users? [y/N]" - if ($reply -match '^[Yy]') { - Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force - Write-Host "Execution policy set to RemoteSigned for LocalMachine." -ForegroundColor Green - } - else { - Write-Host " Skipped LocalMachine policy. PS5 may not load the profile if CurrentUser is overridden." -ForegroundColor Yellow - } - } - else { - Write-Host " Skipped LocalMachine policy prompt (non-interactive mode)." -ForegroundColor Yellow - } + Write-Host "LocalMachine execution policy is '$machinePolicy'." -ForegroundColor Yellow + Write-Host " setup.ps1 leaves machine-wide policy unchanged. Change it manually only if you intend to affect all users." -ForegroundColor DarkYellow } } @@ -88,6 +863,7 @@ function Install-NerdFonts { [string]$Version = "3.2.1" ) + $tempRoot = $null try { [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Drawing") $fontCollection = New-Object System.Drawing.Text.InstalledFontCollection @@ -96,16 +872,12 @@ function Install-NerdFonts { if ($fontFamilies -notcontains "${FontDisplayName}") { Write-Host " Installing ${FontDisplayName}..." -ForegroundColor Yellow $fontZipUrl = "https://github.com/ryanoasis/nerd-fonts/releases/download/v${Version}/${FontName}.zip" - $zipFilePath = "$env:TEMP\${FontName}.zip" - $extractPath = "$env:TEMP\${FontName}" + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("psp-font-" + [System.IO.Path]::GetRandomFileName()) + $zipFilePath = Join-Path $tempRoot "${FontName}.zip" + $extractPath = Join-Path $tempRoot "extract" - $webClient = New-Object System.Net.WebClient - try { - $webClient.DownloadFile((New-Object System.Uri($fontZipUrl)), $zipFilePath) - } - finally { - $webClient.Dispose() - } + New-Item -ItemType Directory -Path $extractPath -Force | Out-Null + Invoke-DownloadWithRetry -Uri $fontZipUrl -OutFile $zipFilePath -TimeoutSec 60 -MaxAttempts 2 if (-not (Test-Path $zipFilePath) -or (Get-Item $zipFilePath).Length -eq 0) { throw "Font download is missing or empty" } @@ -134,11 +906,14 @@ function Install-NerdFonts { } } + Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' + $tempRoot = $null if ($copied -gt 0 -and $pending) { - Write-Host " Warning: font copy timed out, $(@($pending).Count) file(s) may not have installed." -ForegroundColor Yellow + # Partial install: some files never appeared under %SystemRoot%\Fonts within the + # timeout. Report failure so callers can surface it instead of claiming success. + Write-Host " Font copy timed out: $(@($pending).Count) of $copied file(s) did not install." -ForegroundColor Red + return $false } - Remove-Item -Path $extractPath -Recurse -Force - Remove-Item -Path $zipFilePath -Force Write-Host " ${FontDisplayName} installed." -ForegroundColor Green return $true } @@ -148,45 +923,60 @@ function Install-NerdFonts { } } catch { + Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' Write-Host " Failed to install ${FontDisplayName}: $_" -ForegroundColor Red return $false } } -# Download helper with retry, size validation, and corrupt-file cleanup -function Invoke-DownloadWithRetry { +# Return ALL existing WT settings.json across variants so step [10/10] writes to every +# installed variant (Stable + Preview + Canary + unpackaged). DUPLICATED from profile. +# (setup needs its own copy: it runs before the profile it installs exists - chicken-and-egg.) +function Get-WindowsTerminalSettingsPaths { + $candidates = @( + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalCanary_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\settings.json' + ) + @($candidates | Where-Object { Test-Path -LiteralPath $_ }) +} + +# Resolve the real path of an external command, following aliases recursively. +# DUPLICATED from Microsoft.PowerShell_profile.ps1's Get-ExternalCommandPath. +# Keep these two copies in sync per CLAUDE.md "Structural Duplication" guidance. +function Get-ExternalCommandPath { param( [Parameter(Mandatory)] - [string]$Uri, - [Parameter(Mandatory)] - [string]$OutFile, - [int]$TimeoutSec = 10, - [int]$MaxAttempts = 2, - [int]$BackoffSec = 2 + [string]$CommandName ) - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - try { - Remove-Item $OutFile -Force -ErrorAction SilentlyContinue - Invoke-RestMethod -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -ErrorAction Stop - if (-not (Test-Path $OutFile) -or (Get-Item $OutFile).Length -eq 0) { - Remove-Item $OutFile -Force -ErrorAction SilentlyContinue - throw 'Downloaded file is missing or empty' - } - return - } - catch { - if ($attempt -lt $MaxAttempts) { - Write-Host " Download failed (attempt $attempt/$MaxAttempts): $_ Retrying in ${BackoffSec}s..." -ForegroundColor Yellow - Start-Sleep -Seconds $BackoffSec - } - else { - throw $_ - } + + $cmd = Get-Command $CommandName -ErrorAction SilentlyContinue + if (-not $cmd) { return $null } + + if ($cmd.CommandType -eq 'Alias' -and $cmd.Definition -and $cmd.Definition -ne $CommandName) { + return Get-ExternalCommandPath -CommandName $cmd.Definition + } + + $pathCandidates = @($cmd.Path, $cmd.Source, $cmd.Definition) | + Where-Object { $_ -and [System.IO.Path]::IsPathRooted([string]$_) } | + Select-Object -Unique + foreach ($pathCandidate in $pathCandidates) { + if (Test-Path -LiteralPath $pathCandidate -PathType Leaf) { + return $pathCandidate } } + + return $null +} + +function Update-SessionPathFromRegistry { + $machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + $userPath = [System.Environment]::GetEnvironmentVariable('PATH', 'User') + $env:PATH = (@($machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' } -# Resolve oh-my-posh executable path (Get-Command or known install locations) +# Resolve oh-my-posh executable path (Get-ExternalCommandPath or known install locations) function Get-OhMyPoshExecutablePath { $candidatePaths = @( (Join-Path $env:LOCALAPPDATA 'Programs\oh-my-posh\bin\oh-my-posh.exe'), @@ -199,8 +989,7 @@ function Get-OhMyPoshExecutablePath { $candidatePaths += (Join-Path $pf86 'oh-my-posh\bin\oh-my-posh.exe') } - $cmd = Get-Command oh-my-posh -ErrorAction SilentlyContinue - $resolvedPath = if ($cmd -and $cmd.Path -and (Test-Path -LiteralPath $cmd.Path -PathType Leaf)) { $cmd.Path } else { $null } + $resolvedPath = Get-ExternalCommandPath -CommandName 'oh-my-posh' $windowsAppsRoot = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' if ($resolvedPath -and $resolvedPath -notlike "$windowsAppsRoot*") { return $resolvedPath @@ -212,7 +1001,7 @@ function Get-OhMyPoshExecutablePath { $candidateDir = Split-Path -Path $candidatePath -Parent $pathEntries = @($env:PATH -split ';' | Where-Object { $_ }) if ($pathEntries -notcontains $candidateDir) { - $env:PATH = $candidateDir + ';' + $env:PATH + $env:PATH = (@($candidateDir, $env:PATH) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' } return $candidatePath @@ -221,9 +1010,9 @@ function Get-OhMyPoshExecutablePath { return $null } -# Check for internet connectivity before proceeding (skip when using a local repo) -if (-not $LocalRepo -and -not (Test-InternetConnection)) { - return +# Check for internet connectivity before proceeding (skip when using a local repo or a verified remote bundle) +if (-not $LocalRepo -and -not $script:VerifiedInstallBundle -and -not (Test-InternetConnection)) { + if ($PSCommandPath) { exit 1 } else { return } } # JSONC comment-stripping regex (built via variable to avoid PS5 parser bug with [^"] in strings) @@ -237,14 +1026,78 @@ if (!(Test-Path -Path $configCachePath)) { New-Item -Path $configCachePath -ItemType "directory" -Force | Out-Null } -try { - $configTmp = Join-Path $env:TEMP "theme.json" - if ($LocalRepo) { - Copy-Item (Join-Path $LocalRepo 'theme.json') $configTmp -Force -ErrorAction Stop +# Install wizard. Writes user choices to user-settings.json BEFORE downstream steps, so +# the rest of setup.ps1 (WT sync, font install, cache writes) picks up those overrides +# via the existing user-settings merge logic. Non-interactive hosts always skip. +$canRunWizard = [Environment]::UserInteractive -and -not $isCiHost -and -not [bool]$env:AI_AGENT +$wantWizard = ($Wizard -or $canRunWizard) -and -not $SkipWizard +if ($wantWizard) { + $wizardState = Join-Path $env:TEMP 'psp-wizard-state.json' + if (-not $Resume -and (Test-Path $wizardState)) { + # Stale state file from a previous aborted run; the wizard itself asks if we resume, + # but if -Resume was not passed explicitly, clean out any state older than 24h. + $age = (Get-Date) - (Get-Item $wizardState).LastWriteTime + if ($age.TotalHours -gt 24) { Remove-Item $wizardState -Force -ErrorAction SilentlyContinue } } - else { - Invoke-DownloadWithRetry -Uri "$RepoBase/theme.json" -OutFile $configTmp + try { + $wizChoices = Start-InstallWizard -StatePath $wizardState + $userSettingsPath = Join-Path $configCachePath 'user-settings.json' + Save-WizardChoices -Choices $wizChoices -UserSettingsPath $userSettingsPath + Write-Host '' + Write-Host 'Wizard choices saved to user-settings.json.' -ForegroundColor Green + + # If user picked a Nerd Font, override the version-pinned font install step + # by re-invoking Install-NerdFonts with the chosen asset/display name + latest release. + # Only mark WizardFontInstalled = $true when the install actually succeeded so step + # [4/10] falls back to the default install instead of silently leaving no font. + if ($wizChoices.Font) { + $latestVer = Get-LatestNerdFontVersion + Write-Host ("Installing Nerd Font: {0} v{1}..." -f $wizChoices.Font.DisplayName, $latestVer) -ForegroundColor Cyan + $wizardFontOk = Install-NerdFonts -FontName $wizChoices.Font.Asset -FontDisplayName $wizChoices.Font.DisplayName -Version $latestVer + if ($wizardFontOk) { + $script:WizardFontInstalled = $true + } + else { + Write-Host " Wizard font install did not complete; step [4/10] will retry with the default font." -ForegroundColor Yellow + } + } + + # If OMP theme chosen, write its URL so downstream OMP install uses that instead of + # theme.json's default. We do this by patching the fetched $profileConfig below. + $script:WizardOmpTheme = $wizChoices.Theme + + # Expose editor + telemetry choices so [2/10] and the end-of-setup prompt skip + # their own interactive prompts (the wizard already captured the user's answer). + $script:WizardEditor = $wizChoices.Editor + $script:WizardTelemetryHandled = ($null -ne $wizChoices.TelemetryOptOut) + + # Apply telemetry opt-out immediately (we are already elevated inside setup.ps1). + # Ownership marker lets Uninstall-Profile know the value is ours and safe to remove. + if ($wizChoices.TelemetryOptOut -and -not [System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine')) { + try { + [System.Environment]::SetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'true', [System.EnvironmentVariableTarget]::Machine) + $telemetryMarker = Join-Path $configCachePath 'telemetry.owned' + [System.IO.File]::WriteAllText($telemetryMarker, "set by setup.ps1 wizard at $(Get-Date -Format o)`n", [System.Text.UTF8Encoding]::new($false)) + Write-Host 'Telemetry opt-out applied from wizard choice.' -ForegroundColor DarkGray + } + catch { + Write-Host " Failed to apply telemetry opt-out: $_" -ForegroundColor Yellow + } + } + } + catch { + if ($_.Exception.Message -eq 'WizardCancelled') { + Write-Host 'Continuing setup without wizard overrides.' -ForegroundColor Yellow + } + else { + Write-Warning ("Wizard failed: {0}. Continuing with defaults." -f $_.Exception.Message) + } } +} + +try { + $configTmp = Join-Path $env:TEMP ("psp-theme-" + [System.IO.Path]::GetRandomFileName() + ".json") + Copy-Item (Resolve-SetupSourcePath -Kind 'theme') $configTmp -Force -ErrorAction Stop $profileConfig = Get-Content $configTmp -Raw | ConvertFrom-Json Copy-Item $configTmp (Join-Path $configCachePath "theme.json") -Force Remove-Item $configTmp -ErrorAction SilentlyContinue @@ -256,13 +1109,8 @@ catch { # Download terminal-config.json (WT behavior settings: scrollbar, historySize, keybindings) $terminalConfig = $null try { - $terminalConfigTmp = Join-Path $env:TEMP "terminal-config.json" - if ($LocalRepo) { - Copy-Item (Join-Path $LocalRepo 'terminal-config.json') $terminalConfigTmp -Force -ErrorAction Stop - } - else { - Invoke-DownloadWithRetry -Uri "$RepoBase/terminal-config.json" -OutFile $terminalConfigTmp - } + $terminalConfigTmp = Join-Path $env:TEMP ("psp-terminal-" + [System.IO.Path]::GetRandomFileName() + ".json") + Copy-Item (Resolve-SetupSourcePath -Kind 'terminal') $terminalConfigTmp -Force -ErrorAction Stop $terminalConfig = Get-Content $terminalConfigTmp -Raw | ConvertFrom-Json Copy-Item $terminalConfigTmp (Join-Path $configCachePath "terminal-config.json") -Force Remove-Item $terminalConfigTmp -ErrorAction SilentlyContinue @@ -273,7 +1121,8 @@ catch { # Merge helper - deep-merges PSCustomObjects so nested keys are preserved function Merge-JsonObject($base, $override) { - if (-not $base -or -not $override) { return } + if ($null -eq $override) { return } + if ($null -eq $base) { throw 'Merge-JsonObject: $base cannot be null (caller must pass an object to merge into).' } foreach ($prop in $override.PSObject.Properties) { $baseVal = $base.PSObject.Properties[$prop.Name] if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) { @@ -379,11 +1228,13 @@ if (Test-Path $userSettingsPath) { } } -# Check for winget availability +# winget is optional. The profile + config steps below do not need it, and every tool +# installer (Install-WingetPackage, the editor install) already skips gracefully when it is +# absent. Warn and continue instead of aborting, so LTSC/offline/locked-down hosts still get +# the profile (matches README: optional tools "degrade gracefully if a tool is absent"). if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - Write-Host "winget (App Installer) is required but not found." -ForegroundColor Red - Write-Host "Install it from the Microsoft Store or https://aka.ms/getwinget" -ForegroundColor Yellow - return + Write-Host "winget (App Installer) not found - skipping optional tool installs (eza, bat, fzf, zoxide, ripgrep, Oh My Posh)." -ForegroundColor Yellow + Write-Host " Install it later from the Microsoft Store or https://aka.ms/getwinget to add those tools." -ForegroundColor DarkYellow } Write-Host "" @@ -393,7 +1244,6 @@ Write-Host "" # Profile creation or update (install for both PS5 and PS7) Write-Host "[1/10] Profile" -ForegroundColor Cyan -$profileUrl = "$RepoBase/Microsoft.PowerShell_profile.ps1" # Derive Documents root from $PROFILE (works correctly even when Documents is in OneDrive) $docsRoot = Split-Path (Split-Path $PROFILE) $profileDirs = @( @@ -408,17 +1258,21 @@ foreach ($dir in $profileDirs) { New-Item -Path $dir -ItemType "directory" -Force | Out-Null } # Copy/download to temp first so a partial/corrupt download never overwrites the existing profile - $tempDownload = Join-Path $env:TEMP "profile_download_$(Split-Path $dir -Leaf).ps1" - if ($LocalRepo) { - Copy-Item (Join-Path $LocalRepo 'Microsoft.PowerShell_profile.ps1') $tempDownload -Force - } - else { - Invoke-DownloadWithRetry -Uri $profileUrl -OutFile $tempDownload -TimeoutSec 30 - } + $tempDownload = Join-Path $env:TEMP ("psp-profile_download_" + (Split-Path $dir -Leaf) + "_" + [System.IO.Path]::GetRandomFileName() + ".ps1") + Copy-Item (Resolve-SetupSourcePath -Kind 'profile') $tempDownload -Force -ErrorAction Stop if (Test-Path -Path $targetProfile -PathType Leaf) { - $backupPath = Join-Path $dir "oldprofile.ps1" + # Timestamped + rolling so a second install never destroys the first backup. + # Matches the WT settings backup pattern (keep last 5). Older "oldprofile.ps1" + # (pre-timestamp format) is also swept by the rolling cleanup below. + $backupStamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $dir ("oldprofile.$backupStamp.ps1") Copy-Item -Path $targetProfile -Destination $backupPath -Force Write-Host " Backup saved to [$backupPath]" -ForegroundColor DarkGray + $oldBackups = Get-ChildItem -Path $dir -Filter 'oldprofile*.ps1' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 + foreach ($old in $oldBackups) { + Remove-Item $old.FullName -Force -ErrorAction SilentlyContinue + } } Move-Item -Path $tempDownload -Destination $targetProfile -Force Write-Host " Profile installed at [$targetProfile]" -ForegroundColor Green @@ -466,12 +1320,32 @@ $userSettingsTemplate = Join-Path $configCachePath "user-settings.json" if (-not (Test-Path $userSettingsTemplate)) { $settingsContent = @' { - "_comment": "User overrides for terminal and theme settings. Only add keys you want to override.", + "_comment": "User overrides for terminal, theme, and profile behavior. Only add keys you want to override.", "_examples": { "theme": { "name": "catppuccin", "url": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/catppuccin.omp.json" }, "windowsTerminal": { "colorScheme": "One Half Dark", "cursorColor": "#ffffff" }, - "defaults": { "opacity": 90, "font": { "size": 14 } }, - "keybindings": [{ "keys": "ctrl+shift+t", "command": { "action": "newTab" } }] + "defaults": { + "opacity": 90, + "font": { "size": 14 }, + "backgroundImage": "%USERPROFILE%\\Pictures\\bg.png", + "backgroundImageOpacity": 0.3, + "backgroundImageStretchMode": "uniformToFill", + "backgroundImageAlignment": "center" + }, + "keybindings": [{ "keys": "ctrl+shift+t", "command": { "action": "newTab" } }], + "features": { + "psfzf": true, + "predictions": true, + "startupMessage": true, + "perDirProfiles": true, + "_commandOverrides_note": "set commandOverrides to true ONLY if you also populate the commandOverrides section below. Default off because JSON strings get compiled to scriptblocks at profile load.", + "commandOverrides": false + }, + "commandOverrides": { + "_note": "entries here are ignored unless features.commandOverrides = true", + "gs": "git status --short" + }, + "trustedDirs": [] } } '@ @@ -483,48 +1357,80 @@ else { Write-Host " User settings file already exists at [$userSettingsTemplate] (preserved)" -ForegroundColor DarkGray } -# Editor preference (interactive prompt writes $script:EditorPriority into profile_user.ps1) -Write-Host "[2/10] Editor preference" -ForegroundColor Cyan -$canPromptEditor = [Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT -if ($canPromptEditor) { try { $null = [Console]::KeyAvailable } catch { $canPromptEditor = $false } } -if ($canPromptEditor) { - $chosenEditor = Select-PreferredEditor - # Install chosen editor via winget only if not already installed - $chosen = $EditorCandidates | Where-Object { $_.Cmd -eq $chosenEditor } | Select-Object -First 1 - if ($chosen -and $chosen.WingetId -and -not (Get-Command $chosenEditor -ErrorAction SilentlyContinue)) { - if (Get-Command winget -ErrorAction SilentlyContinue) { +function Set-PreferredEditorInProfiles { + param( + [Parameter(Mandatory)][string]$EditorName, + [Parameter(Mandatory)][string]$CommentLabel + ) + $editorLine = '$script:EditorPriority = @(' + "'$EditorName', 'notepad'" + ')' + foreach ($dir in $profileDirs) { + $userProfilePath = Join-Path $dir "profile_user.ps1" + if (-not (Test-Path $userProfilePath)) { continue } + $content = [System.IO.File]::ReadAllText($userProfilePath) + if ($content -match '(?m)^\$script:EditorPriority\s*=') { + $content = $content -replace '(?m)^\$script:EditorPriority\s*=.*$', $editorLine + } + else { + $content = $content.TrimEnd() + "`r`n`r`n# --- Preferred editor ($CommentLabel) ---`r`n$editorLine`r`n" + } + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($userProfilePath, $content, $utf8NoBom) + } +} + +if ($script:DownloadedProfilePath) { Remove-Item $script:DownloadedProfilePath -Force -ErrorAction SilentlyContinue } +if ($script:DownloadedThemeConfigPath) { Remove-Item $script:DownloadedThemeConfigPath -Force -ErrorAction SilentlyContinue } +if ($script:DownloadedTerminalConfigPath) { Remove-Item $script:DownloadedTerminalConfigPath -Force -ErrorAction SilentlyContinue } + +function Resolve-ConfiguredEditor { + param([Parameter(Mandatory)][string]$RequestedEditor) + $chosen = $EditorCandidates | Where-Object { $_.Cmd -eq $RequestedEditor } | Select-Object -First 1 + $resolvedEditor = $RequestedEditor + if ($chosen -and $chosen.WingetId -and -not (Get-Command $RequestedEditor -ErrorAction SilentlyContinue)) { + if ($isCiHost) { + Write-Host " CI mode: skipping editor install for $($chosen.Display)." -ForegroundColor DarkGray + $resolvedEditor = 'notepad' + } + elseif (Get-Command winget -ErrorAction SilentlyContinue) { Write-Host " Installing $($chosen.Display) via winget..." -ForegroundColor Cyan $null = winget install -e --id $chosen.WingetId --accept-source-agreements --accept-package-agreements 2>&1 if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq -1978335185 -or $LASTEXITCODE -eq -1978335189) { - $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + # Filter null/empty before joining: a missing User PATH would otherwise produce + # a trailing ';' in $env:PATH, which Windows path parsing has historically + # interpreted as "include CWD" - a classic command-hijack surface during setup. + Update-SessionPathFromRegistry Write-Host " $($chosen.Display) installed." -ForegroundColor Green } else { Write-Host " Could not install $($chosen.Display) via winget. Using Notepad." -ForegroundColor Yellow - $chosenEditor = 'notepad' + $resolvedEditor = 'notepad' } } else { Write-Host " winget not found. Using Notepad." -ForegroundColor Yellow - $chosenEditor = 'notepad' + $resolvedEditor = 'notepad' } } + return $resolvedEditor +} + +# Editor preference (interactive prompt writes $script:EditorPriority into profile_user.ps1). +# When the wizard already captured an editor choice we skip the prompt and use it directly. +Write-Host "[2/10] Editor preference" -ForegroundColor Cyan +$canPromptEditor = [Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT +if ($canPromptEditor) { try { $null = [Console]::KeyAvailable } catch { $canPromptEditor = $false } } +if ($script:WizardEditor) { + $chosenEditor = [string]$script:WizardEditor + Write-Host " Using wizard choice: $chosenEditor" -ForegroundColor DarkGray + $chosenEditor = Resolve-ConfiguredEditor -RequestedEditor $chosenEditor Write-Host " Editor set to: $chosenEditor" -ForegroundColor Green - $editorLine = '$script:EditorPriority = @(' + "'$chosenEditor', 'notepad'" + ')' - foreach ($dir in $profileDirs) { - $userProfilePath = Join-Path $dir "profile_user.ps1" - if (Test-Path $userProfilePath) { - $content = [System.IO.File]::ReadAllText($userProfilePath) - if ($content -match '(?m)^\$script:EditorPriority\s*=') { - $content = $content -replace '(?m)^\$script:EditorPriority\s*=.*$', $editorLine - } - else { - $content = $content.TrimEnd() + "`r`n`r`n# --- Preferred editor (set by setup.ps1) ---`r`n$editorLine`r`n" - } - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($userProfilePath, $content, $utf8NoBom) - } - } + Set-PreferredEditorInProfiles -EditorName $chosenEditor -CommentLabel 'set by setup.ps1 wizard' +} +elseif ($canPromptEditor) { + $chosenEditor = Select-PreferredEditor + $chosenEditor = Resolve-ConfiguredEditor -RequestedEditor $chosenEditor + Write-Host " Editor set to: $chosenEditor" -ForegroundColor Green + Set-PreferredEditorInProfiles -EditorName $chosenEditor -CommentLabel 'set by setup.ps1' } else { Write-Host " Skipped (non-interactive). Default: code, notepad" -ForegroundColor Yellow @@ -540,6 +1446,9 @@ function Install-OhMyPoshTheme { ) $themeFilePath = Join-Path $configCachePath "$ThemeName.omp.json" try { + if (-not (Test-IsTrustedRawGitHubUrl -Url $ThemeUrl)) { + throw "Refusing to download theme from untrusted host: $ThemeUrl" + } $alreadyValid = $false if (Test-Path -LiteralPath $themeFilePath -PathType Leaf) { try { @@ -600,10 +1509,18 @@ if ($ompPath -and $ompPath -notlike ((Join-Path $env:LOCALAPPDATA 'Microsoft\Win Write-Host " Oh My Posh already present at $ompPath (preserved)." -ForegroundColor Green $ompInstalled = $true } +elseif ($isCiHost) { + Write-Host " CI mode: skipping Oh My Posh install." -ForegroundColor DarkGray + $ompInstalled = $true +} else { $ompInstalled = Install-WingetPackage -Name "Oh My Posh" -Id "JanDeDobbeleer.OhMyPosh" } -if ($profileConfig -and $profileConfig.theme -and $profileConfig.theme.name -and $profileConfig.theme.url) { +# Wizard OMP-theme override wins if present; otherwise use theme.json default. +if ($script:WizardOmpTheme) { + $themeInstalled = Install-OhMyPoshTheme -ThemeName $script:WizardOmpTheme.Name -ThemeUrl $script:WizardOmpTheme.Url +} +elseif ($profileConfig -and $profileConfig.theme -and $profileConfig.theme.name -and $profileConfig.theme.url) { $themeInstalled = Install-OhMyPoshTheme -ThemeName $profileConfig.theme.name -ThemeUrl $profileConfig.theme.url } else { @@ -628,23 +1545,54 @@ if ($terminalConfig -and $terminalConfig.fontInstall) { if ($terminalConfig.fontInstall.displayName) { $fontDisplayName = $terminalConfig.fontInstall.displayName } if ($terminalConfig.fontInstall.version) { $fontVersion = $terminalConfig.fontInstall.version } } -$fontInstalled = Install-NerdFonts -FontName $fontName -FontDisplayName $fontDisplayName -Version $fontVersion +if ($script:WizardFontInstalled) { + Write-Host " Font already installed by wizard; skipping default." -ForegroundColor DarkGray + $fontInstalled = $true +} +elseif ($isCiHost) { + Write-Host " CI mode: skipping Nerd Font install." -ForegroundColor DarkGray + $fontInstalled = $true +} +else { + $fontInstalled = Install-NerdFonts -FontName $fontName -FontDisplayName $fontDisplayName -Version $fontVersion +} # eza Install (modern ls replacement with icons and git status) Write-Host "[5/10] eza" -ForegroundColor Cyan -$ezaInstalled = Install-WingetPackage -Name "eza" -Id "eza-community.eza" +$ezaInstalled = $true +if ($isCiHost) { + Write-Host " CI mode: skipping eza install." -ForegroundColor DarkGray +} +else { + $ezaInstalled = Install-WingetPackage -Name "eza" -Id "eza-community.eza" +} # Clean up leftover Terminal-Icons if present Remove-Module Terminal-Icons -Force -ErrorAction SilentlyContinue Uninstall-Module Terminal-Icons -AllVersions -Force -ErrorAction SilentlyContinue # zoxide Install Write-Host "[6/10] zoxide" -ForegroundColor Cyan -$zoxideInstalled = Install-WingetPackage -Name "zoxide" -Id "ajeetdsouza.zoxide" +$zoxideInstalled = $true +if ($isCiHost) { + Write-Host " CI mode: skipping zoxide install." -ForegroundColor DarkGray +} +else { + $zoxideInstalled = Install-WingetPackage -Name "zoxide" -Id "ajeetdsouza.zoxide" +} # fzf + PSFzf Install (fuzzy finder for history and file search) Write-Host "[7/10] fzf" -ForegroundColor Cyan -$fzfInstalled = Install-WingetPackage -Name "fzf" -Id "junegunn.fzf" -if (-not (Get-Module -ListAvailable -Name PSFzf)) { +$fzfInstalled = $true +if ($isCiHost) { + Write-Host " CI mode: skipping fzf install." -ForegroundColor DarkGray +} +else { + $fzfInstalled = Install-WingetPackage -Name "fzf" -Id "junegunn.fzf" +} +if ($isCiHost) { + Write-Host " CI mode: skipping PSFzf module install." -ForegroundColor DarkGray +} +elseif (-not (Get-Module -ListAvailable -Name PSFzf)) { try { Install-Module -Name PSFzf -Scope CurrentUser -Force -AllowClobber Write-Host " PSFzf module installed." -ForegroundColor Green @@ -660,16 +1608,32 @@ else { # bat Install (syntax-highlighted cat replacement) Write-Host "[8/10] bat" -ForegroundColor Cyan -$batInstalled = Install-WingetPackage -Name "bat" -Id "sharkdp.bat" +$batInstalled = $true +if ($isCiHost) { + Write-Host " CI mode: skipping bat install." -ForegroundColor DarkGray +} +else { + $batInstalled = Install-WingetPackage -Name "bat" -Id "sharkdp.bat" +} # ripgrep Install (fast recursive grep, used by the grep function) Write-Host "[9/10] ripgrep" -ForegroundColor Cyan -$rgInstalled = Install-WingetPackage -Name "ripgrep" -Id "BurntSushi.ripgrep.MSVC" +$rgInstalled = $true +if ($isCiHost) { + Write-Host " CI mode: skipping ripgrep install." -ForegroundColor DarkGray +} +else { + $rgInstalled = Install-WingetPackage -Name "ripgrep" -Id "BurntSushi.ripgrep.MSVC" +} -# Windows Terminal configuration (merges font, theme, and appearance into existing settings) +# Windows Terminal configuration (merges font, theme, and appearance into existing settings). +# Iterates ALL installed WT variants so Stable + Preview + Canary all receive the merge. Write-Host "[10/10] Windows Terminal" -ForegroundColor Cyan -$wtSettingsPath = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json" -if (Test-Path $wtSettingsPath) { +$wtSettingsPaths = Get-WindowsTerminalSettingsPaths +if (-not $wtSettingsPaths -or $wtSettingsPaths.Count -eq 0) { + Write-Host " Windows Terminal settings not found (skipped)." -ForegroundColor Yellow +} +foreach ($wtSettingsPath in $wtSettingsPaths) { try { # Backup original (ConvertTo-Json strips JSONC comments and may reorder keys) $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" @@ -750,6 +1714,19 @@ if (Test-Path $wtSettingsPath) { $wt.schemes = @(@($wt.schemes | Where-Object { $_ -and $_.name -ne $schemeDef.name }) + $schemeDef) } + # Upsert custom WT theme (tab bar colors, window chrome) from config + if ($profileConfig -and $profileConfig.windowsTerminal -and $profileConfig.windowsTerminal.themeDefinition) { + $themeDef = [PSCustomObject]$profileConfig.windowsTerminal.themeDefinition + if (-not $wt.themes) { + $wt | Add-Member -NotePropertyName "themes" -NotePropertyValue @() -Force + } + $wt.themes = @(@($wt.themes | Where-Object { $_ -and $_.name -ne $themeDef.name }) + $themeDef) + } + if ($profileConfig -and $profileConfig.windowsTerminal -and $profileConfig.windowsTerminal.theme) { + if ($wt.PSObject.Properties['theme']) { $wt.theme = $profileConfig.windowsTerminal.theme } + else { $wt | Add-Member -NotePropertyName "theme" -NotePropertyValue $profileConfig.windowsTerminal.theme -Force } + } + # Ensure PowerShell profiles launch with -NoLogo to suppress # the copyright banner and "Loading personal and system profiles took ..." message if ($wt.profiles.list) { @@ -797,28 +1774,65 @@ if (Test-Path $wtSettingsPath) { } } - $wtJson = $wt | ConvertTo-Json -Depth 10 + # Depth 100: WT settings can have deeply nested action/command objects; + # depth 10 silently truncates those to their type name string and corrupts settings. + $wtJson = $wt | ConvertTo-Json -Depth 100 $utf8NoBom = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($wtSettingsPath, $wtJson, $utf8NoBom) $schemeLabel = if ($cfgColorScheme) { $cfgColorScheme } else { "(unchanged)" } Write-Host " Windows Terminal configured (scheme: $schemeLabel)." -ForegroundColor Green } catch { - Write-Host " Failed to configure Windows Terminal: $_" -ForegroundColor Red + Write-Host " Failed to configure Windows Terminal ($wtSettingsPath): $_" -ForegroundColor Red } } -else { - Write-Host " Windows Terminal settings not found (skipped)." -ForegroundColor Yellow + +# Optional: PowerShell telemetry opt-out (explicit consent, machine-wide env var, requires admin). +# Previously this was written silently on every admin shell from the profile; moved here so users +# see the prompt and understand the scope. Skipped in non-interactive / CI / agent contexts and +# when not elevated (env var lives in HKLM). +$canPromptTelemetry = [Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT +if ($canPromptTelemetry) { try { $null = [Console]::KeyAvailable } catch { $canPromptTelemetry = $false } } +$isElevatedSetup = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if ($script:WizardTelemetryHandled) { + # Wizard already captured the user's choice and (if applicable) applied it; do not re-prompt. +} +elseif ($canPromptTelemetry -and $isElevatedSetup -and -not [System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine')) { + Write-Host "" + Write-Host "Opt out of PowerShell telemetry? This sets POWERSHELL_TELEMETRY_OPTOUT=true machine-wide." -ForegroundColor Cyan + $answer = Read-Host " [y/N]" + if ($answer -match '^(?i:y|yes)$') { + try { + [System.Environment]::SetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'true', [System.EnvironmentVariableTarget]::Machine) + # Ownership marker so Uninstall-Profile knows this value is ours and safe to remove. + # Without the marker, uninstall leaves an existing env var alone (user may have set + # it themselves for other tools). + try { + $telemetryMarker = Join-Path $configCachePath 'telemetry.owned' + [System.IO.File]::WriteAllText($telemetryMarker, "set by setup.ps1 at $(Get-Date -Format o)`n", [System.Text.UTF8Encoding]::new($false)) + } + catch { $null = $_ } + Write-Host " Telemetry opt-out applied." -ForegroundColor Green + } + catch { + Write-Host " Failed to set env var: $_" -ForegroundColor Yellow + } + } } -# Final summary +# Final summary. Exit code is driven ONLY by the core deliverable (the profile). The optional tools +# (Oh My Posh, Nerd Font, eza, zoxide, fzf, bat, ripgrep) degrade gracefully per README, so a skipped +# or failed optional install -- or a missing winget -- is a warning, never a non-zero exit. Write-Host "" -$allGood = $profileInstalled -and $themeInstalled -and $fontInstalled -and $ompInstalled -and $ezaInstalled -and $zoxideInstalled -and $fzfInstalled -and $batInstalled -and $rgInstalled -if ($allGood) { - Write-Host "Setup complete!" -ForegroundColor Green +$optionalOk = $themeInstalled -and $fontInstalled -and $ompInstalled -and $ezaInstalled -and $zoxideInstalled -and $fzfInstalled -and $batInstalled -and $rgInstalled +if (-not $profileInstalled) { + Write-Host "Setup FAILED: the profile could not be installed. Check the messages above." -ForegroundColor Red +} +elseif (-not $optionalOk) { + Write-Host "Setup complete - profile installed. Some optional tools were skipped or failed (see above); the profile works without them." -ForegroundColor Yellow } else { - Write-Host "Setup completed with some issues. Check the messages above." -ForegroundColor Yellow + Write-Host "Setup complete!" -ForegroundColor Green } Write-Host "" # AI_AGENT or CI = skip "Press Enter to restart" (agent/AI/automation context) @@ -842,8 +1856,8 @@ if ($canPromptExit) { $shellExe = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh.exe" } else { "powershell.exe" } Start-Process -FilePath $shellExe -ArgumentList "-NoExit" -WorkingDirectory $dir } - exit ([int](-not $allGood)) + exit ([int](-not $profileInstalled)) } if ($MyInvocation.PSCommandPath) { - exit ([int](-not $allGood)) + exit ([int](-not $profileInstalled)) } diff --git a/ci-functional.ps1 b/tests/ci-functional.ps1 similarity index 59% rename from ci-functional.ps1 rename to tests/ci-functional.ps1 index b7f4c5a..7c3e8ac 100644 --- a/ci-functional.ps1 +++ b/tests/ci-functional.ps1 @@ -8,7 +8,9 @@ param( $ErrorActionPreference = 'Stop' -$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +# This script lives in tests/. Resolve repo root (parent of tests/) so all relative +# references to Microsoft.PowerShell_profile.ps1, setup.ps1, theme.json etc. still work. +$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) $profilePath = Join-Path $repoRoot 'Microsoft.PowerShell_profile.ps1' $passed = 0 @@ -233,46 +235,56 @@ if (-not (Test-Path $profilePath)) { } try { -Invoke-TestCase -Name 'Full install flow on host (setup.ps1)' -Code { - # Run the real setup.ps1 against the current host to validate the full install flow - # (winget tools, fonts, Windows Terminal settings). - # - # Behavior: - # - In CI/non-admin environments: call setup.ps1 -CiMode so admin-only steps are skipped - # but the rest of the flow still executes. - # - Locally (non-CI): require elevation so users see the real install behavior. - - $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - $isCiHost = [bool]$env:GITHUB_ACTIONS -or [bool]$env:CI - - if (-not $isElevated -and -not $isCiHost) { - throw 'setup.ps1 requires an elevated (Administrator) shell when run locally. Run ci-functional.ps1 from an elevated pwsh so the full install flow can be validated.' - } - +Invoke-TestCase -Name 'Full install flow in sandbox (setup.ps1)' -Code { + # Run the real setup.ps1 against a disposable sandbox so the install flow is validated + # without mutating the developer's actual profile, LOCALAPPDATA, or WT settings. $setupPath = Join-Path $repoRoot 'setup.ps1' if (-not (Test-Path $setupPath)) { throw "setup.ps1 not found at $setupPath" } - Write-Host ' Running setup.ps1 against host environment (full install flow).' -ForegroundColor Yellow - # Hashtable splatting is required for named parameters. - # Array splatting passes elements positionally, which would bind '-LocalRepo' - # to the first positional param ($Opacity) and fail the int conversion. - $setupArgs = @{ LocalRepo = $repoRoot } - if ($isCiHost -and -not $isElevated) { - $setupArgs['CiMode'] = $true + $sandboxRoot = Join-Path $env:TEMP "psp-ci-setup-$([System.IO.Path]::GetRandomFileName())" + $sandboxLocal = Join-Path $sandboxRoot 'Local' + $sandboxDocs = Join-Path $sandboxRoot 'Documents' + $sandboxPs7Dir = Join-Path $sandboxDocs 'PowerShell' + $sandboxPs5Dir = Join-Path $sandboxDocs 'WindowsPowerShell' + $sandboxPs7Profile = Join-Path $sandboxPs7Dir 'Microsoft.PowerShell_profile.ps1' + $sandboxPs5Profile = Join-Path $sandboxPs5Dir 'Microsoft.PowerShell_profile.ps1' + $sandboxCacheDir = Join-Path $sandboxLocal 'PowerShellProfile' + $sandboxWtLocalState = Join-Path $sandboxLocal 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + + New-Item -ItemType Directory -Path $sandboxPs7Dir, $sandboxPs5Dir, $sandboxWtLocalState -Force | Out-Null + [System.IO.File]::WriteAllText( + (Join-Path $sandboxWtLocalState 'settings.json'), + '{"profiles":{"defaults":{"font":{"face":"Consolas"}},"list":[]},"schemes":[],"actions":[]}', + $utf8NoBom + ) + + $prevLocal = $env:LOCALAPPDATA + $prevProfile = $PROFILE + try { + $env:LOCALAPPDATA = $sandboxLocal + Set-Variable -Name PROFILE -Scope Global -Value $sandboxPs7Profile + + Write-Host ' Running setup.ps1 against disposable sandbox.' -ForegroundColor Yellow + & $setupPath -LocalRepo $repoRoot -CiMode -SkipWizard + + if (-not (Test-Path $sandboxPs7Profile)) { throw "setup.ps1 did not install PS7 profile at $sandboxPs7Profile" } + if (-not (Test-Path $sandboxPs5Profile)) { throw "setup.ps1 did not install PS5 profile at $sandboxPs5Profile" } + if (-not (Test-Path $sandboxCacheDir)) { throw "setup.ps1 did not create cache dir at $sandboxCacheDir" } + + $wtSettingsPath = Join-Path $sandboxWtLocalState 'settings.json' + $wtSettings = Get-Content $wtSettingsPath -Raw | ConvertFrom-Json + if (-not $wtSettings.profiles.defaults) { + throw "setup.ps1 did not merge Windows Terminal defaults in sandbox settings" + } + } + finally { + $env:LOCALAPPDATA = $prevLocal + Set-Variable -Name PROFILE -Scope Global -Value $prevProfile + Remove-Item -Path $sandboxRoot -Recurse -Force -ErrorAction SilentlyContinue } - & $setupPath @setupArgs - - # Verify setup actually installed files (setup.ps1 uses return, not exit 1, so - # $LASTEXITCODE is always 0 - we must check artifacts directly) - $docsRoot = Split-Path (Split-Path $PROFILE) - $ps7Profile = Join-Path $docsRoot 'PowerShell' 'Microsoft.PowerShell_profile.ps1' - $ps5Profile = Join-Path $docsRoot 'WindowsPowerShell' 'Microsoft.PowerShell_profile.ps1' - if (-not (Test-Path $ps7Profile)) { throw "setup.ps1 did not install PS7 profile at $ps7Profile" } - if (-not (Test-Path $ps5Profile)) { throw "setup.ps1 did not install PS5 profile at $ps5Profile" } - $cacheDir = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' - if (-not (Test-Path $cacheDir)) { throw "setup.ps1 did not create cache dir at $cacheDir" } } Invoke-TestCase -Name 'Install profile in sandbox' -Code { @@ -452,21 +464,56 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Invoke-CommandProbe -Command 'Edit-Profile' -SkipReason 'Opens interactive editor' Invoke-CommandProbe -Command 'ep' -SkipReason 'Alias to Edit-Profile (opens interactive editor)' Invoke-CommandProbe -Command 'edit' -SkipReason 'Opens interactive editor' - Invoke-CommandProbe -Command 'Update-Profile' -SkipReason 'Network and mutating profile update flow' - Invoke-CommandProbe -Command 'Update-PowerShell' -SkipReason 'Installs or upgrades shell binaries' - Invoke-CommandProbe -Command 'Update-Tools' -SkipReason 'Installs or upgrades external tools' + # Update flows are network/mutation-heavy; we can't run them end-to-end in CI, but at + # least verify the parameter contract so renames/removals don't silently break muscle + # memory, and drive the safe early-exit paths each function exposes. + Invoke-CommandProbe -Command 'Update-Profile' -Code { + $cmd = Get-Command Update-Profile + foreach ($p in @('Force', 'SkipHashCheck', 'ExpectedSha256', 'WhatIf')) { + if (-not $cmd.Parameters.ContainsKey($p)) { throw "Update-Profile missing expected parameter: $p" } + } + # -WhatIf must trip the ShouldProcess gate BEFORE the Phase 1 downloads so + # no psp-profile-*.ps1 / psp-theme-*.json / psp-terminal-*.json appears in %TEMP%. + $filters = @('psp-profile-*.ps1', 'psp-theme-*.json', 'psp-terminal-*.json') + $pre = foreach ($f in $filters) { Get-ChildItem -Path $env:TEMP -Filter $f -ErrorAction SilentlyContinue } + Update-Profile -WhatIf -Confirm:$false | Out-Null + $post = foreach ($f in $filters) { Get-ChildItem -Path $env:TEMP -Filter $f -ErrorAction SilentlyContinue } + if (@($post).Count -gt @($pre).Count) { + throw "Update-Profile -WhatIf created temp files (download ran): $(($post | Select-Object -ExpandProperty Name) -join ', ')" + } + } + Invoke-CommandProbe -Command 'Update-PowerShell' -Code { + # Safe early exits: PS5 prints a guidance message and returns; PS7 without winget + # prints a warning. Either way the function must not throw on first invocation. + Update-PowerShell *> $null + } + Invoke-CommandProbe -Command 'Update-Tools' -Code { + # Safe early exit when winget is absent; with winget we still need skip because + # it would actually mutate installed tools. Only run when winget is unavailable. + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host ' (skipping live invocation; winget present in CI host)' -ForegroundColor DarkGray + } + else { + Update-Tools *> $null + } + $cmd = Get-Command Update-Tools + if (-not $cmd) { throw 'Update-Tools missing' } + } Invoke-CommandProbe -Command 'Clear-ProfileCache' -Code { $origLocal = $env:LOCALAPPDATA try { $fakeLocal = Join-Path $workspace 'localappdata' $fakeCache = Join-Path $fakeLocal 'PowerShellProfile' - New-Item -ItemType Directory -Path $fakeCache -Force | Out-Null + $fakePlugins = Join-Path $fakeCache 'plugins' + New-Item -ItemType Directory -Path $fakePlugins -Force | Out-Null [System.IO.File]::WriteAllText((Join-Path $fakeCache 'transient.cache'), 'x', $utf8NoBom) [System.IO.File]::WriteAllText((Join-Path $fakeCache 'user-settings.json'), '{}', $utf8NoBom) + [System.IO.File]::WriteAllText((Join-Path $fakePlugins 'myplugin.ps1'), '# user plugin', $utf8NoBom) $env:LOCALAPPDATA = $fakeLocal Clear-ProfileCache if (Test-Path (Join-Path $fakeCache 'transient.cache')) { throw 'Clear-ProfileCache did not remove transient file' } if (-not (Test-Path (Join-Path $fakeCache 'user-settings.json'))) { throw 'Clear-ProfileCache removed user-settings.json' } + if (-not (Test-Path (Join-Path $fakePlugins 'myplugin.ps1'))) { throw 'Clear-ProfileCache removed user plugin' } } finally { $env:LOCALAPPDATA = $origLocal @@ -474,6 +521,27 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { } Invoke-CommandProbe -Command 'Clear-Cache' -Code { Clear-Cache -WhatIf -Confirm:$false | Out-Null } Invoke-CommandProbe -Command 'Uninstall-Profile' -Code { Uninstall-Profile -All -WhatIf -Confirm:$false | Out-Null } + Invoke-CommandProbe -Command 'Invoke-ProfileWizard' -Code { + # SupportsShouldProcess lets us drive the invocation path without network download. + # -WhatIf returns before Invoke-DownloadWithRetry, so this is safe in CI. + # We also verify the ShouldProcess gate actually prevents the temp download by + # ensuring no psp-reconfigure-*.ps1 files exist in %TEMP% after the -WhatIf call. + $preFiles = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*.ps1' -ErrorAction SilentlyContinue) + Invoke-ProfileWizard -WhatIf -Confirm:$false | Out-Null + $postFiles = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*.ps1' -ErrorAction SilentlyContinue) + if ($postFiles.Count -gt $preFiles.Count) { + throw "Invoke-ProfileWizard -WhatIf created temp files it should not have: $($postFiles.Name -join ', ')" + } + $cmd = Get-Command Invoke-ProfileWizard + foreach ($p in @('Resume', 'NoElevate', 'ExpectedSha256', 'BundleExpectedSha256', 'SkipHashCheck', 'WhatIf')) { + if (-not $cmd.Parameters.ContainsKey($p)) { throw "Invoke-ProfileWizard missing expected parameter: $p" } + } + } + Invoke-CommandProbe -Command 'Reconfigure-Profile' -Code { + $alias = Get-Alias Reconfigure-Profile -ErrorAction SilentlyContinue + if (-not $alias) { throw 'Reconfigure-Profile alias missing' } + if ($alias.ResolvedCommandName -ne 'Invoke-ProfileWizard') { throw "Reconfigure-Profile points to $($alias.ResolvedCommandName), expected Invoke-ProfileWizard" } + } Invoke-CommandProbe -Command 'Invoke-DownloadWithRetry' -SkipReason 'Internal helper (covered by setup tests)' Invoke-CommandProbe -Command 'Invoke-WithTimeout' -SkipReason 'Internal helper (used by OMP/zoxide init)' Invoke-CommandProbe -Command 'Merge-JsonObject' -SkipReason 'Internal helper nested in Update-Profile' @@ -594,6 +662,55 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { } finally { Set-Location $before } } + Invoke-CommandProbe -Command 'cdh' -Code { + # Seed the stack by cd'ing through two dirs, invoking Invoke-PromptStage + # manually since the probe does not render a prompt. + $before = Get-Location + try { + $sub = Join-Path $workspace 'cdh-probe' + New-Item -ItemType Directory -Path $sub -Force | Out-Null + Set-Location -LiteralPath $sub + Invoke-PromptStage + Set-Location -LiteralPath $workspace + Invoke-PromptStage + $out = cdh | Out-String + if ($out -notmatch 'cdh-probe') { throw "cdh output missing seeded entry: $out" } + } + finally { Set-Location $before } + } + Invoke-CommandProbe -Command 'cdb' -Code { + $before = Get-Location + try { + $sub = Join-Path $workspace 'cdb-probe' + New-Item -ItemType Directory -Path $sub -Force | Out-Null + Set-Location -LiteralPath $sub + Invoke-PromptStage + Set-Location -LiteralPath $workspace + Invoke-PromptStage + cdb 1 + if ((Get-Location).Path -ne $sub) { throw "cdb 1 did not navigate back; got $((Get-Location).Path)" } + } + finally { Set-Location $before } + } + Invoke-CommandProbe -Command 'duration' -Code { + # Ensure a command is present in Get-History before calling duration + Get-Date | Out-Null + $out = duration | Out-String + if ([string]::IsNullOrWhiteSpace($out)) { throw 'duration produced no output' } + } + Invoke-CommandProbe -Command 'Test-ProfileHealth' -Code { + $report = Test-ProfileHealth + if (-not $report) { throw 'Test-ProfileHealth returned no rows' } + $expected = @('Tools', 'Caches', 'Config', 'PATH', 'Modules') + foreach ($cat in $expected) { + if (-not ($report | Where-Object Category -eq $cat)) { throw "Test-ProfileHealth missing category: $cat" } + } + } + Invoke-CommandProbe -Command 'psp-doctor' -Code { + $alias = Get-Alias psp-doctor -ErrorAction SilentlyContinue + if (-not $alias) { throw 'psp-doctor alias missing' } + if ($alias.ResolvedCommandName -ne 'Test-ProfileHealth') { throw "psp-doctor points to $($alias.ResolvedCommandName)" } + } Invoke-CommandProbe -Command 'bak' -Code { bak $textFile $baks = Get-ChildItem -Path $workspace -Filter 'sample.txt.*.bak' -ErrorAction SilentlyContinue @@ -646,8 +763,8 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Invoke-CommandProbe -Command 'speedtest' -SkipReason 'Long-running network benchmark' Invoke-CommandProbe -Command 'wifipass' -SkipReason 'Requires WLAN profile context and often elevation' Invoke-CommandProbe -Command 'hosts' -SkipReason 'Opens elevated editor UI' - Invoke-CommandProbe -Command 'winutil' -SkipReason 'Downloads and executes external script' - Invoke-CommandProbe -Command 'harden' -Code { harden | Out-Null } + Invoke-CommandProbe -Command 'winutil' -SkipReason 'Remote-script wrapper is unit-tested separately; live run intentionally skipped' + Invoke-CommandProbe -Command 'harden' -SkipReason 'Launches external hardening tool / GUI on developer machines' # Security and crypto $sha256 = $null @@ -660,8 +777,12 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { checksum $textFile $script:sha256 | Out-Null } Invoke-CommandProbe -Command 'genpass' -Code { - $p = genpass 16 - if (-not $p -or $p.Length -ne 16) { throw 'genpass did not return 16-character password' } + # genpass is clipboard-only by contract (never returns/prints the plaintext), so assert the + # clipboard receives a 16-char password rather than a return value. + Set-Clipboard '' + genpass 16 + $clip = ((Get-Clipboard) -join '').Trim() + if (-not $clip -or $clip.Length -ne 16) { throw "genpass did not copy a 16-character password to clipboard (got length $($clip.Length))" } } -SkipReason $clipboardSkipReason Invoke-CommandProbe -Command 'b64' -Code { $enc = (b64 'hello world' | Out-String).Trim() @@ -675,6 +796,19 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $sampleJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' jwtd $sampleJwt | Out-Null } + Invoke-CommandProbe -Command 'Test-ProfileHistorySafeLine' -Code { + if (Test-ProfileHistorySafeLine 'pwnd hunter2') { throw 'pwnd input allowed into history' } + if (Test-ProfileHistorySafeLine 'jwtd eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig') { throw 'jwt decode allowed into history' } + foreach ($leaky in @('$p = pwnd hunter2', 'cls; pwnd hunter2', 'history | pwnd hunter2')) { + if (Test-ProfileHistorySafeLine $leaky) { throw "pwnd not scrubbed in chained form: $leaky" } + } + foreach ($secret in @('git remote add o https://ghp_abcdefghij1234567890ABCDEFGH@github.com', 'export K=AKIAIOSFODNN7EXAMPLE', 'mysql -uroot -pHunter2024')) { + if (Test-ProfileHistorySafeLine $secret) { throw "value-shaped secret allowed into history: $secret" } + } + foreach ($safe in @('git status --short', 'mkdir -p src/lib', 'docker run -p 8080:80 nginx')) { + if (-not (Test-ProfileHistorySafeLine $safe)) { throw "safe command blocked from history: $safe" } + } + } Invoke-CommandProbe -Command 'uuid' -Code { uuid | Out-Null } -SkipReason $clipboardSkipReason Invoke-CommandProbe -Command 'epoch' -Code { $now = [int64]((epoch | Out-String).Trim()) @@ -690,7 +824,7 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $dec = (urldecode 'hello%20world' | Out-String).Trim() if ($dec -ne 'hello world') { throw "urldecode returned unexpected value: $dec" } } - Invoke-CommandProbe -Command 'vtscan' -SkipReason 'Requires VirusTotal API key and uploads content' + Invoke-CommandProbe -Command 'vtscan' -SkipReason 'Requires VirusTotal API key; -Upload submits content' Invoke-CommandProbe -Command 'vt' -Code { if (Get-Command vt.exe -ErrorAction SilentlyContinue) { vt --help | Out-Null } else { vt | Out-Null } @@ -698,6 +832,22 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { # Developer Invoke-CommandProbe -Command 'killport' -SkipReason 'Destructive process termination command' + Invoke-CommandProbe -Command 'Stop-ListeningPort' -SkipReason 'Interactive fzf picker; destructive' + Invoke-CommandProbe -Command 'killports' -SkipReason 'Alias to Stop-ListeningPort' + Invoke-CommandProbe -Command 'Find-FileLocker' -Code { + # Lock a temp file and verify Find-FileLocker reports our own PID. + $lockFile = Join-Path $workspace 'lock-probe.txt' + [System.IO.File]::WriteAllText($lockFile, 'x', $utf8NoBom) + $stream = [System.IO.File]::Open($lockFile, 'Open', 'ReadWrite', 'None') + try { + $lockers = @(Find-FileLocker $lockFile) + $selfPid = $PID + if (-not ($lockers | Where-Object PID -eq $selfPid)) { throw "Find-FileLocker did not report self PID $selfPid for locked file" } + } + finally { $stream.Close(); $stream.Dispose() } + } + Invoke-CommandProbe -Command 'Stop-StuckProcess' -SkipReason 'Destructive process termination' + Invoke-CommandProbe -Command 'Remove-LockedItem' -SkipReason 'Destructive: kills processes and deletes' Invoke-CommandProbe -Command 'http' -Code { $response = http "http://127.0.0.1:$httpPort/" -Method GET | Out-String if ($response -notmatch '"ok"\s*:\s*true') { throw "unexpected http response: $response" } @@ -722,10 +872,27 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Invoke-CommandProbe -Command 'dprune' -SkipReason 'Destructive: prunes docker resources' # SSH and remote + Invoke-CommandProbe -Command 'ssh' -SkipReason 'Wraps native ssh.exe; would initiate real TCP connect' Invoke-CommandProbe -Command 'Copy-SshKey' -SkipReason 'Requires reachable remote host' Invoke-CommandProbe -Command 'ssh-copy-key' -SkipReason 'Alias requiring reachable remote host' Invoke-CommandProbe -Command 'keygen' -SkipReason 'Writes SSH keys to user profile' Invoke-CommandProbe -Command 'rdp' -SkipReason 'Opens Remote Desktop UI' + Invoke-CommandProbe -Command 'wsl' -SkipReason 'Wraps wsl.exe; would launch a distro shell' + Invoke-CommandProbe -Command 'Get-WslDistro' -Code { + # Parses wsl -l -v; safe to call. May return empty list if no distros installed on runner. + Get-WslDistro | Out-Null + } + Invoke-CommandProbe -Command 'Enter-WslHere' -SkipReason 'Opens interactive WSL shell' + Invoke-CommandProbe -Command 'wsl-here' -SkipReason 'Alias to Enter-WslHere (interactive)' + Invoke-CommandProbe -Command 'ConvertTo-WslPath' -SkipReason 'Requires at least one installed WSL distro' + Invoke-CommandProbe -Command 'ConvertTo-WindowsPath' -SkipReason 'Requires at least one installed WSL distro' + Invoke-CommandProbe -Command 'Stop-Wsl' -SkipReason 'Destructive: terminates running distros' + Invoke-CommandProbe -Command 'Get-WslIp' -SkipReason 'Requires a running WSL distro' + Invoke-CommandProbe -Command 'Get-WslFile' -SkipReason 'Requires a running WSL distro (UNC path access)' + Invoke-CommandProbe -Command 'Show-WslTree' -SkipReason 'Requires a running WSL distro' + Invoke-CommandProbe -Command 'wsl-tree' -SkipReason 'Alias to Show-WslTree; requires a running WSL distro' + Invoke-CommandProbe -Command 'Open-WslExplorer' -SkipReason 'Opens Windows Explorer; requires running distro' + Invoke-CommandProbe -Command 'wsl-explorer' -SkipReason 'Alias to Open-WslExplorer' # Clipboard Invoke-CommandProbe -Command 'cpy' -Code { cpy 'psp-ci-clipboard' } -SkipReason $clipboardSkipReason @@ -736,6 +903,171 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $invokeClipboardSkipReason = if ($clipboardSkipReason) { $clipboardSkipReason } else { 'Clipboard insertion behavior is host-dependent in headless sessions' } Invoke-CommandProbe -Command 'Invoke-Clipboard' -SkipReason $invokeClipboardSkipReason Invoke-CommandProbe -Command 'icb' -SkipReason $invokeClipboardSkipReason + + # Sysadmin / Linux-feel + Invoke-CommandProbe -Command 'journal' -Code { journal -Count 5 | Out-Null } + Invoke-CommandProbe -Command 'lsblk' -Code { lsblk | Out-Null } + Invoke-CommandProbe -Command 'htop' -SkipReason 'Launches interactive TUI process viewer' + Invoke-CommandProbe -Command 'mtr' -SkipReason 'Long-running traceroute + per-hop ping loop' + Invoke-CommandProbe -Command 'fwallow' -SkipReason 'Mutates Windows Firewall rules (requires elevation)' + Invoke-CommandProbe -Command 'fwblock' -SkipReason 'Mutates Windows Firewall rules (requires elevation)' + + # Cybersec + Invoke-CommandProbe -Command 'nscan' -SkipReason 'Requires nmap binary' + Invoke-CommandProbe -Command 'sigcheck' -Code { + $sysExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (-not (Test-Path $sysExe)) { throw "Expected signed host binary not found: $sysExe" } + sigcheck $sysExe | Out-Null + } + Invoke-CommandProbe -Command 'ads' -Code { + $adsFile = Join-Path $workspace 'ads-target.txt' + [System.IO.File]::WriteAllText($adsFile, 'primary', $utf8NoBom) + Set-Content -LiteralPath $adsFile -Stream 'zone.hint' -Value 'ci-marker' + $result = @(ads $adsFile) + $match = $result | Where-Object { $_.Stream -eq 'zone.hint' } + if (-not $match) { throw "ads did not return object for zone.hint stream" } + } + Invoke-CommandProbe -Command 'defscan' -SkipReason 'Triggers Windows Defender scan' + Invoke-CommandProbe -Command 'pwnd' -Code { pwnd 'password' | Out-Null } + Invoke-CommandProbe -Command 'certcheck' -Code { certcheck 'example.com' | Out-Null } + Invoke-CommandProbe -Command 'entropy' -Code { entropy $textFile | Out-Null } + + # Developer+ + Invoke-CommandProbe -Command 'serve' -SkipReason 'Long-running HTTP server (blocks)' + Invoke-CommandProbe -Command 'gitignore' -Code { + $before = Get-Location + try { + $giDir = Join-Path $workspace 'gi-target' + New-Item -ItemType Directory -Path $giDir -Force | Out-Null + Set-Location $giDir + gitignore 'python' | Out-Null + if (-not (Test-Path (Join-Path $giDir '.gitignore'))) { throw 'gitignore did not create .gitignore' } + } + finally { Set-Location $before } + } + Invoke-CommandProbe -Command 'gcof' -SkipReason 'Interactive fzf branch picker' + Invoke-CommandProbe -Command 'envload' -Code { + $envFile = Join-Path $workspace 'probe.env' + [System.IO.File]::WriteAllText($envFile, "PSP_ENVLOAD_TEST=ok`n# comment`nexport PSP_ENVLOAD_QUOTED=`"quoted value`"", $utf8NoBom) + envload $envFile | Out-Null + if ($env:PSP_ENVLOAD_TEST -ne 'ok') { throw "envload did not set PSP_ENVLOAD_TEST: $env:PSP_ENVLOAD_TEST" } + if ($env:PSP_ENVLOAD_QUOTED -ne 'quoted value') { throw "envload did not strip quotes: $env:PSP_ENVLOAD_QUOTED" } + } + Invoke-CommandProbe -Command 'tldr' -Code { tldr 'ls' | Out-Null } + Invoke-CommandProbe -Command 'repeat' -Code { + $script:repeatCount = 0 + repeat 3 { $script:repeatCount++ } | Out-Null + if ($script:repeatCount -ne 3) { throw "repeat expected 3 iterations, got $script:repeatCount" } + } + Invoke-CommandProbe -Command 'mkvenv' -SkipReason 'Creates Python venv directory and activates it' + + # Detection / AST + Invoke-CommandProbe -Command 'outline' -Code { + $entries = @(outline $profilePath) + $fnHit = $entries | Where-Object { $_.Kind -eq 'Function' -and $_.Name -eq 'Update-Profile' } + if (-not $fnHit) { throw 'outline did not emit Function entry for Update-Profile' } + } + Invoke-CommandProbe -Command 'psym' -Code { psym 'Update-Profile' $repoRoot | Out-Null } + Invoke-CommandProbe -Command 'lint' -SkipReason 'Requires PSScriptAnalyzer module; covered by lint job' + Invoke-CommandProbe -Command 'Find-DeadCode' -Code { + $fixture = Join-Path $workspace 'dead.ps1' + [System.IO.File]::WriteAllText($fixture, "function used { param(`$a, `$b) `$a }`nused 1 2", $utf8NoBom) + Find-DeadCode $fixture | Out-Null + } + Invoke-CommandProbe -Command 'Test-Profile' -Code { Test-Profile | Out-Null } + Invoke-CommandProbe -Command 'Get-PwshVersions' -Code { Get-PwshVersions | Out-Null } + Invoke-CommandProbe -Command 'modinfo' -Code { + $info = @(modinfo 'PSReadLine') + if (-not $info) { throw 'modinfo returned nothing for PSReadLine' } + if ($info[0].Name -ne 'PSReadLine') { throw "modinfo Name mismatch: $($info[0].Name)" } + } + Invoke-CommandProbe -Command 'psgrep' -Code { psgrep 'Update-Profile' $repoRoot -Kind Function | Out-Null } + + # Extensibility + Invoke-CommandProbe -Command 'Register-ProfileHook' -Code { + $script:hookFired = 0 + Register-ProfileHook -EventName 'OnProfileLoad' -Action { $script:hookFired++ } + if ($script:PSP.Hooks.OnProfileLoad.Count -lt 1) { throw 'Register-ProfileHook did not add hook' } + } + Invoke-CommandProbe -Command 'Register-HelpSection' -Code { + $before = $script:PSP.HelpSections.Count + Register-HelpSection -Title 'CI Probe Section' -Lines @('line one', 'line two') + if ($script:PSP.HelpSections.Count -ne $before + 1) { throw 'Register-HelpSection did not add section' } + } + Invoke-CommandProbe -Command 'Register-ProfileCommand' -Code { + $before = $script:PSP.Commands.Count + Register-ProfileCommand -Name 'probe-cmd' -Category 'Probe' -Synopsis 'ci probe' + if ($script:PSP.Commands.Count -ne $before + 1) { throw 'Register-ProfileCommand did not add command' } + } + Invoke-CommandProbe -Command 'Get-ProfileCommand' -Code { + $all = Get-ProfileCommand + if (-not $all -or $all.Count -lt 50) { throw "Get-ProfileCommand returned only $($all.Count) entries" } + $filtered = Get-ProfileCommand -Category 'Git' + if (-not $filtered -or $filtered.Count -lt 3) { throw 'Category filter returned too few' } + } + Invoke-CommandProbe -Command 'Start-ProfileTour' -SkipReason 'Interactive walkthrough (Read-Host loop)' + Invoke-CommandProbe -Command 'Add-TrustedDirectory' -Code { + $origSettingsPath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\user-settings.json' + $origContent = if (Test-Path $origSettingsPath) { Get-Content $origSettingsPath -Raw } else { $null } + try { + $trustDir = Join-Path $workspace 'trust-target' + New-Item -ItemType Directory -Path $trustDir -Force | Out-Null + Add-TrustedDirectory -Path $trustDir -Confirm:$false + if ($script:PSP.TrustedDirs -notcontains (Resolve-Path $trustDir).ProviderPath) { + throw 'Add-TrustedDirectory did not register dir' + } + # Regression: parse-failure must NOT overwrite existing user-settings.json. + [System.IO.File]::WriteAllText($origSettingsPath, '{ this is not valid json', $utf8NoBom) + $preCorruptContent = Get-Content $origSettingsPath -Raw + $trustDir2 = Join-Path $workspace 'trust-target-2' + New-Item -ItemType Directory -Path $trustDir2 -Force | Out-Null + $trustedBefore = @($script:PSP.TrustedDirs).Count + Add-TrustedDirectory -Path $trustDir2 -Confirm:$false -ErrorAction SilentlyContinue + $postContent = Get-Content $origSettingsPath -Raw + if ($postContent -ne $preCorruptContent) { throw 'Add-TrustedDirectory overwrote corrupt user-settings.json instead of aborting' } + # Regression: failed Save must roll back in-memory Add so state matches disk. + $trustedAfter = @($script:PSP.TrustedDirs).Count + if ($trustedAfter -ne $trustedBefore) { throw "Add-TrustedDirectory did not roll back in-memory state (before=$trustedBefore after=$trustedAfter)" } + } + finally { + if ($origContent) { [System.IO.File]::WriteAllText($origSettingsPath, $origContent, $utf8NoBom) } + } + } + Invoke-CommandProbe -Command 'Remove-TrustedDirectory' -Code { + $origSettingsPath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\user-settings.json' + $origContent = if (Test-Path $origSettingsPath) { Get-Content $origSettingsPath -Raw } else { $null } + try { + $trustDir = Join-Path $workspace 'untrust-target' + New-Item -ItemType Directory -Path $trustDir -Force | Out-Null + Add-TrustedDirectory -Path $trustDir -Confirm:$false + Remove-TrustedDirectory -Path $trustDir -Confirm:$false + if ($script:PSP.TrustedDirs -contains (Resolve-Path $trustDir).ProviderPath) { + throw 'Remove-TrustedDirectory did not remove dir' + } + } + finally { + if ($origContent) { [System.IO.File]::WriteAllText($origSettingsPath, $origContent, $utf8NoBom) } + } + } + Invoke-CommandProbe -Command 'Set-TerminalBackground' -Code { + $origSettingsPath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\user-settings.json' + $origContent = if (Test-Path $origSettingsPath) { Get-Content $origSettingsPath -Raw } else { $null } + try { + $bgImg = Join-Path $workspace 'probe-bg.png' + # Minimal PNG header + IEND to satisfy file-exists check + $pngBytes = [byte[]](0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + [System.IO.File]::WriteAllBytes($bgImg, $pngBytes) + Set-TerminalBackground -Path $bgImg -Opacity 0.5 -Confirm:$false + $settings = Get-Content $origSettingsPath -Raw | ConvertFrom-Json + if (-not $settings.defaults.backgroundImage) { throw 'Set-TerminalBackground did not persist image' } + Set-TerminalBackground -Clear -Confirm:$false + $settings2 = Get-Content $origSettingsPath -Raw | ConvertFrom-Json + if ($settings2.defaults.PSObject.Properties['backgroundImage']) { throw 'Set-TerminalBackground -Clear did not remove image' } + } + finally { + if ($origContent) { [System.IO.File]::WriteAllText($origSettingsPath, $origContent, $utf8NoBom) } + } + } } finally { if ($httpJob) { @@ -779,6 +1111,7 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { # Internal helper functions that are not direct end-user commands $internalOnly = @( 'Get-ExternalCommandPath' + 'Update-SessionPathFromRegistry' 'Get-OhMyPoshInstallInfo' 'Get-OhMyPoshMsiProductCode' 'Get-OhMyPoshExecutablePath' @@ -793,6 +1126,23 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { 'Invoke-WithTimeout' 'Restart-TerminalToApply' 'Clear-OhMyPoshCaches' + 'Write-JournalLine' + 'Invoke-PromptStage' + 'Invoke-ProfileHook' + 'Test-ProfileHistorySafeLine' + 'Save-TrustedDirectories' + 'Read-UserSettingsForWrite' + 'Get-WindowsTerminalSettingsPath' + 'Get-WindowsTerminalSettingsPaths' + 'Push-TabTitle' + 'Pop-TabTitle' + 'Resolve-WslUncPath' + 'Initialize-RestartManagerType' + 'ConvertTo-NativeArgumentLine' + 'Get-Utf8FileText' + 'Write-Utf8FileAtomic' + 'Set-PspFeatureOverride' + 'Get-LatestMainCommitSha' ) $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ } diff --git a/tests/lint.ps1 b/tests/lint.ps1 new file mode 100644 index 0000000..5e84fdb --- /dev/null +++ b/tests/lint.ps1 @@ -0,0 +1,19 @@ +# Local PSScriptAnalyzer runner. Mirrors CI rule set and excludes tests/ (which intentionally +# uses aliases, generates strings that match the secrets scan regex, etc.). +$repoRoot = Split-Path -Parent $PSScriptRoot +# Pin to the same version CI uses (.github/workflows/ci.yml) so local and remote see the same +# rule set and don't diverge on new analyzer releases. +$pinnedPSSAVersion = '1.24.0' +$have = Get-Module -ListAvailable -Name PSScriptAnalyzer | Where-Object { $_.Version -eq [version]$pinnedPSSAVersion } +if (-not $have) { + Install-Module -Name PSScriptAnalyzer -RequiredVersion $pinnedPSSAVersion -Force -Scope CurrentUser -ErrorAction Stop +} +Import-Module PSScriptAnalyzer -RequiredVersion $pinnedPSSAVersion -ErrorAction Stop +$results = Invoke-ScriptAnalyzer -Path $repoRoot -Recurse -ExcludeRule PSAvoidUsingWriteHost,PSAvoidUsingWMICmdlet,PSUseShouldProcessForStateChangingFunctions,PSUseBOMForUnicodeEncodedFile,PSReviewUnusedParameter,PSUseSingularNouns | + Where-Object { $_.ScriptName -notin @('ci-functional.ps1', 'rawhunt.ps1', 'test.ps1', 'locallab.ps1', 'lint.ps1') } +if ($results) { + $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + exit 1 +} else { + Write-Host 'PSScriptAnalyzer: clean' -ForegroundColor Green +} diff --git a/tests/locallab.ps1 b/tests/locallab.ps1 new file mode 100644 index 0000000..8dd4361 --- /dev/null +++ b/tests/locallab.ps1 @@ -0,0 +1,306 @@ +# locallab.ps1 +# Local test + install harness. Tracked in git; run from repo root. +# +# Typical usage: +# pwsh -NoProfile -File tests/locallab.ps1 # run all non-destructive checks +# pwsh -NoProfile -File tests/locallab.ps1 -Install # tests + replace live profile (minimal) +# pwsh -NoProfile -File tests/locallab.ps1 -FullInstall # tests + setup.ps1 -LocalRepo (winget tools too) +# pwsh -NoProfile -File tests/locallab.ps1 -Wizard # tests + setup.ps1 -LocalRepo -Wizard (implies -FullInstall) +# pwsh -NoProfile -File tests/locallab.ps1 -Functional # also run tests/ci-functional.ps1 (elevated recommended) +# pwsh -NoProfile -File tests/locallab.ps1 -Restore # restore profile(s) from last backup + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [switch]$SkipLint, + [switch]$SkipParse, + [switch]$SkipSmoke, + [switch]$SkipTest, + [switch]$Functional, + [switch]$Install, + [switch]$FullInstall, + [switch]$Wizard, + [switch]$Restore +) + +# -Wizard implies -FullInstall (wizard only meaningful when driving setup.ps1) +if ($Wizard -and -not $FullInstall) { $FullInstall = $true } + +$ErrorActionPreference = 'Stop' +# This script lives in tests/. repoRoot is the parent directory (where setup.ps1 + profile live). +$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $repoRoot + +$script:failures = @() +$script:step = 0 +function Step { param([string]$Name) + $script:step++ + Write-Host '' + Write-Host ("[{0}] {1}" -f $script:step, $Name) -ForegroundColor Cyan +} +function Fail { param([string]$Name, [string]$Detail) + Write-Host " FAIL $Name $Detail" -ForegroundColor Red + $script:failures += "${Name}: $Detail" +} +function Ok { param([string]$Name, [string]$Detail = '') + if ($Detail) { Write-Host " OK $Name ($Detail)" -ForegroundColor Green } + else { Write-Host " OK $Name" -ForegroundColor Green } +} + +# --- Restore flow: find latest backup and put it back --- +if ($Restore) { + Step 'Restore live profile from backup' + $backups = Get-ChildItem -Path $env:TEMP -Directory -Filter 'psp-locallab-backup-*' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending + if (-not $backups) { + Fail 'Restore' "No psp-locallab-backup-* directory found in $env:TEMP" + exit 1 + } + $latest = $backups[0] + Write-Host " Using: $($latest.FullName)" -ForegroundColor DarkGray + $docsRoot = Split-Path (Split-Path $PROFILE) + $targets = @( + @{ Src = Join-Path $latest.FullName 'PowerShell\Microsoft.PowerShell_profile.ps1'; Dst = Join-Path $docsRoot 'PowerShell\Microsoft.PowerShell_profile.ps1' } + @{ Src = Join-Path $latest.FullName 'WindowsPowerShell\Microsoft.PowerShell_profile.ps1'; Dst = Join-Path $docsRoot 'WindowsPowerShell\Microsoft.PowerShell_profile.ps1' } + ) + foreach ($t in $targets) { + if (Test-Path $t.Src) { + if ($PSCmdlet.ShouldProcess($t.Dst, "Restore from $($t.Src)")) { + Copy-Item -LiteralPath $t.Src -Destination $t.Dst -Force + Ok 'restored' $t.Dst + } + } + else { + Write-Host " (skip: $($t.Src) not in backup)" -ForegroundColor DarkGray + } + } + exit 0 +} + +# --- Validate files exist --- +Step 'Repo structure' +foreach ($f in @('Microsoft.PowerShell_profile.ps1', 'setup.ps1', 'setprofile.ps1', 'theme.json', 'terminal-config.json', 'tests/ci-functional.ps1')) { + if (Test-Path $f) { Ok $f } + else { Fail $f 'missing'; } +} + +# --- 1. PSScriptAnalyzer (mirror CI) --- +if (-not $SkipLint) { + Step 'PSScriptAnalyzer' + try { + if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) { + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -ErrorAction Stop + } + Import-Module PSScriptAnalyzer -ErrorAction Stop + $excluded = @( + 'PSAvoidUsingWriteHost', 'PSAvoidUsingWMICmdlet', + 'PSUseShouldProcessForStateChangingFunctions', 'PSUseBOMForUnicodeEncodedFile', + 'PSReviewUnusedParameter', 'PSUseSingularNouns' + ) + # Mirror tests/lint.ps1 and CI: exclude all tests/ harnesses (they intentionally use aliases, + # generate strings that match the secrets regex, etc.) and any untracked _*.ps1 scratch files. + $testHarnesses = @('ci-functional.ps1', 'rawhunt.ps1', 'test.ps1', 'locallab.ps1', 'lint.ps1') + $results = Invoke-ScriptAnalyzer -Path . -Recurse -ExcludeRule $excluded | + Where-Object { $_.ScriptName -notin $testHarnesses -and $_.ScriptName -notlike '_*.ps1' } + $hits = @($results | Where-Object Severity -in 'Error', 'Warning') + if ($hits) { + $hits | Format-Table ScriptName, Line, RuleName, Message -AutoSize | Out-String | Write-Host + Fail 'lint' "$($hits.Count) warnings/errors" + } + else { Ok 'lint' 'clean' } + } + catch { Fail 'lint' $_.Exception.Message } +} + +# --- 2. PS5 parse-check (uses real powershell.exe) --- +if (-not $SkipParse) { + Step 'PS5 parse-check' + $ps5 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (-not (Test-Path $ps5)) { + Write-Host ' SKIP powershell.exe not available on this host' -ForegroundColor DarkGray + } + else { + $bad = 0 + foreach ($file in (Get-ChildItem -Filter *.ps1 -Recurse -File | Where-Object { $_.Name -notlike '_*.ps1' })) { + $p = $file.FullName + $out = & $ps5 -NoProfile -Command "`$t=`$null; `$e=`$null; [void][System.Management.Automation.Language.Parser]::ParseFile('$p', [ref]`$t, [ref]`$e); if (`$e.Count -gt 0) { foreach (`$x in `$e) { Write-Host (' L' + `$x.Extent.StartLineNumber + ': ' + `$x.Message) }; exit 1 }" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host " FAIL: $p" -ForegroundColor Red + Write-Host $out + $bad++ + } + } + if ($bad -gt 0) { Fail 'PS5 parse' "$bad file(s) failed" } + else { Ok 'PS5 parse' 'clean' } + } +} + +# --- 3. Non-interactive smoke load --- +if (-not $SkipSmoke) { + Step 'Smoke-test non-interactive load' + $env:CI = 'true' + try { + pwsh -NoProfile -NonInteractive -Command ". './Microsoft.PowerShell_profile.ps1'; if (`$LASTEXITCODE) { exit `$LASTEXITCODE }" 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { Ok 'smoke' 'loaded' } + else { Fail 'smoke' "exit $LASTEXITCODE" } + } + finally { Remove-Item env:CI -ErrorAction SilentlyContinue } +} + +# --- 4. Optional: test.ps1 local test suite --- +if (-not $SkipTest -and (Test-Path 'tests/test.ps1')) { + Step 'Run tests/test.ps1 (-SkipPS5)' + $testOutput = & pwsh -NoProfile -File tests/test.ps1 -SkipPS5 2>&1 + $tail = ($testOutput | Select-Object -Last 3) -join [Environment]::NewLine + Write-Host $tail -ForegroundColor DarkGray + if ($LASTEXITCODE -eq 0) { Ok 'tests/test.ps1' } + else { Fail 'tests/test.ps1' "exit $LASTEXITCODE" } +} + +# --- 5. Optional: ci-functional.ps1 --- +if ($Functional) { + Step 'Run ci-functional.ps1' + $elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if (-not $elevated) { + Write-Host ' Note: not elevated. Forcing GITHUB_ACTIONS=true so setup step runs in CiMode.' -ForegroundColor Yellow + $env:GITHUB_ACTIONS = 'true' + } + try { + & pwsh -NoProfile -File tests/ci-functional.ps1 + if ($LASTEXITCODE -eq 0) { Ok 'tests/ci-functional.ps1' } + else { Fail 'tests/ci-functional.ps1' "exit $LASTEXITCODE" } + } + finally { + if (-not $elevated) { Remove-Item env:GITHUB_ACTIONS -ErrorAction SilentlyContinue } + } +} + +# --- 6. Install to live profile dirs (replaces GitHub version) --- +if ($Install -or $FullInstall) { + if ($script:failures.Count -gt 0) { + Write-Host '' + Write-Host "Install blocked: $($script:failures.Count) check(s) failed above." -ForegroundColor Red + exit 1 + } + + Step 'Backup current live profile(s)' + $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupDir = Join-Path $env:TEMP "psp-locallab-backup-$timestamp" + $docsRoot = Split-Path (Split-Path $PROFILE) + New-Item -ItemType Directory -Path $backupDir -Force | Out-Null + $pairs = @( + @{ Name = 'PowerShell'; Path = Join-Path $docsRoot 'PowerShell\Microsoft.PowerShell_profile.ps1' } + @{ Name = 'WindowsPowerShell'; Path = Join-Path $docsRoot 'WindowsPowerShell\Microsoft.PowerShell_profile.ps1' } + ) + foreach ($p in $pairs) { + if (Test-Path $p.Path) { + $dst = Join-Path $backupDir "$($p.Name)\Microsoft.PowerShell_profile.ps1" + New-Item -ItemType Directory -Path (Split-Path $dst) -Force | Out-Null + Copy-Item -LiteralPath $p.Path -Destination $dst -Force + Ok 'backed up' "$($p.Name) -> $dst" + } + else { Write-Host " (skip: $($p.Path) not present)" -ForegroundColor DarkGray } + } + Write-Host " Backup: $backupDir" -ForegroundColor DarkGray + Write-Host ' Restore with: pwsh -NoProfile -File tests/locallab.ps1 -Restore' -ForegroundColor DarkGray + + if ($FullInstall) { + $label = if ($Wizard) { 'setup.ps1 -LocalRepo -Wizard (interactive: theme/scheme/font/features)' } + else { 'setup.ps1 -LocalRepo (full install: tools, fonts, WT settings)' } + Step $label + $elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if (-not $elevated) { Write-Warning 'Not elevated. setup.ps1 needs admin for fonts/winget. Consider rerunning from an elevated pwsh.' } + if ($Wizard) { & './setup.ps1' -LocalRepo $repoRoot -Wizard } + else { & './setup.ps1' -LocalRepo $repoRoot } + if ($LASTEXITCODE -eq 0) { Ok 'setup.ps1' } else { Fail 'setup.ps1' "exit $LASTEXITCODE" } + } + else { + Step 'setprofile.ps1 (minimal: copy profile to PS5 + PS7)' + & './setprofile.ps1' + if ($LASTEXITCODE -eq 0) { Ok 'setprofile.ps1' } else { Fail 'setprofile.ps1' "exit $LASTEXITCODE" } + + # setprofile.ps1 only copies the profile .ps1; refresh cached JSON configs too so + # new schema fields (psreadline.colors, windowsTerminal.themeDefinition, etc.) take + # effect on next pwsh launch without requiring a full setup.ps1 or Update-Profile. + Step 'Refresh cached configs (theme.json, terminal-config.json)' + $cacheDir = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' + if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } + foreach ($cfg in @('theme.json', 'terminal-config.json')) { + $src = Join-Path $repoRoot $cfg + $dst = Join-Path $cacheDir $cfg + if (Test-Path $src) { + Copy-Item -LiteralPath $src -Destination $dst -Force + Ok 'refreshed' $cfg + } + } + # Apply WT tab-bar theme + color scheme directly to WT settings.json. Mirrors the + # subset of Update-Profile Phase 6 / setup.ps1 step [10/10] needed for the cosmetic + # bits to take effect live, without doing network downloads or tool installs. + # PSReadLine colors apply automatically when the new profile loads. + Step 'Apply WT theme to settings.json (live)' + $wtCandidates = @( + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminalCanary_8wekyb3d8bbwe\LocalState\settings.json' + Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\settings.json' + ) + $wtPath = $wtCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $wtPath) { + Write-Host ' (skip: Windows Terminal not installed)' -ForegroundColor DarkGray + } + else { + try { + $themeJson = Get-Content (Join-Path $repoRoot 'theme.json') -Raw | ConvertFrom-Json -ErrorAction Stop + $wtRaw = Get-Content $wtPath -Raw + $q = [char]34 + $jsoncPat = "(?m)(?<=^([^$q]*$q[^$q]*$q)*[^$q]*)\s*//.*`$" + $wt = ($wtRaw -replace $jsoncPat, '') | ConvertFrom-Json -ErrorAction Stop + $backup = "$wtPath.locallab-$((Get-Date).ToString('yyyyMMdd-HHmmss')).bak" + Copy-Item -LiteralPath $wtPath -Destination $backup -Force + # Upsert custom theme + if ($themeJson.windowsTerminal.themeDefinition) { + $td = [PSCustomObject]$themeJson.windowsTerminal.themeDefinition + if (-not $wt.PSObject.Properties['themes']) { + $wt | Add-Member -NotePropertyName 'themes' -NotePropertyValue @() -Force + } + $wt.themes = @(@($wt.themes | Where-Object { $_ -and $_.name -ne $td.name }) + $td) + } + if ($themeJson.windowsTerminal.theme) { + if ($wt.PSObject.Properties['theme']) { $wt.theme = $themeJson.windowsTerminal.theme } + else { $wt | Add-Member -NotePropertyName 'theme' -NotePropertyValue $themeJson.windowsTerminal.theme -Force } + } + # Upsert color scheme + if ($themeJson.windowsTerminal.scheme) { + $sd = [PSCustomObject]$themeJson.windowsTerminal.scheme + if (-not $wt.PSObject.Properties['schemes']) { + $wt | Add-Member -NotePropertyName 'schemes' -NotePropertyValue @() -Force + } + $wt.schemes = @(@($wt.schemes | Where-Object { $_ -and $_.name -ne $sd.name }) + $sd) + } + $out = $wt | ConvertTo-Json -Depth 100 + [System.IO.File]::WriteAllText($wtPath, $out, [System.Text.UTF8Encoding]::new($false)) + Ok 'applied' "WT themes/schemes upserted; backup: $backup" + } + catch { Fail 'apply WT theme' $_.Exception.Message } + } + Write-Host ' PSReadLine colors apply on next pwsh launch.' -ForegroundColor DarkGray + } +} + +# --- Summary --- +Write-Host '' +Write-Host '=========================================' -ForegroundColor Cyan +if ($script:failures.Count -eq 0) { + Write-Host " All checks passed ($script:step step(s))" -ForegroundColor Green + if ($Install -or $FullInstall) { + Write-Host ' Local profile is now live. Open a new pwsh to try it.' -ForegroundColor Green + } + elseif (-not $Restore) { + Write-Host ' To install this version live: pwsh -NoProfile -File tests/locallab.ps1 -Install' -ForegroundColor DarkGray + } + exit 0 +} +else { + Write-Host " $($script:failures.Count) failure(s):" -ForegroundColor Red + $script:failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } + exit 1 +} diff --git a/tests/rawhunt.ps1 b/tests/rawhunt.ps1 new file mode 100644 index 0000000..518a114 --- /dev/null +++ b/tests/rawhunt.ps1 @@ -0,0 +1,1319 @@ +# RAW Bug Hunt - comprehensive profile test suite +# Exercises every profile function, install/uninstall, caching, and config merge +# Run: pwsh -NoProfile -File rawhunt.ps1 +$ErrorActionPreference = 'Continue' + +$ok = 0; $fail = 0; $bugs = @() +function T { + param([string]$Name, [scriptblock]$Code) + try { + $null = & { $ErrorActionPreference = 'Stop'; & $Code } 2>&1 + Write-Host " OK $Name" -ForegroundColor Green + $script:ok++ + } + catch { + $msg = $_.Exception.Message + if ($msg -match 'Timeout|timed out|HttpClient|Unable to connect|SocketException') { + Write-Host " NET $Name ($msg)" -ForegroundColor Yellow + } + else { + Write-Host " BUG $Name ($msg)" -ForegroundColor Red + $script:fail++ + $script:bugs += [PSCustomObject]@{ Command = $Name; Error = $msg } + } + } +} + +function Test-Throws { + param( + [Parameter(Mandatory)] + [scriptblock]$Code, + [string]$Message = 'Expected command to throw' + ) + + $threw = $false + try { + & { $ErrorActionPreference = 'Stop'; & $Code } 2>&1 | Out-Null + } + catch { + $threw = $true + } + + if (-not $threw) { throw $Message } +} + +Write-Host "`n==================== RAW Bug Hunt ====================" -ForegroundColor Cyan + +# This script lives in tests/. repoRoot is the parent directory (where profile + setup.ps1 live). +$repoRoot = Split-Path -Parent $PSScriptRoot +$profilePath = Join-Path $repoRoot 'Microsoft.PowerShell_profile.ps1' +$setupPath = Join-Path $repoRoot 'setup.ps1' + +# Load profile in CI mode (suppresses OMP/zoxide init, network calls) +$env:CI = 'true' +. $profilePath +Remove-Item env:CI + +Write-Host "Profile loaded from: $repoRoot" -ForegroundColor DarkGray + +# Extract nested Merge-JsonObject via AST (lives inside Update-Profile) +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) +$mergeFn = $ast.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -eq 'Merge-JsonObject' +}, $true) | Select-Object -First 1 +if ($mergeFn) { . ([scriptblock]::Create($mergeFn.Extent.Text)) } + +# Extract setup.ps1 functions via AST +$tokens2 = $null; $parseErrors2 = $null +$setupAst = [System.Management.Automation.Language.Parser]::ParseFile($setupPath, [ref]$tokens2, [ref]$parseErrors2) +$setupFns = $setupAst.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) +foreach ($fn in $setupFns) { . ([scriptblock]::Create($fn.Extent.Text)) } +$varDefs = $setupAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $args[0].Left.Extent.Text -eq '$EditorCandidates' +}, $true) +if ($varDefs.Count -gt 0) { . ([scriptblock]::Create($varDefs[0].Extent.Text)) } + +# Workspace +$ws = Join-Path $env:TEMP "psp-raw-$([System.IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $ws -Force | Out-Null +$origDir = Get-Location + +# ##################################################################### +# PART 1: UTILITY FUNCTIONS +# ##################################################################### + +# --- File Operations --- +Write-Host "`n--- File Operations ---" -ForegroundColor Magenta + +$tf = Join-Path $ws "testfile.txt" +"hello world" | Set-Content $tf +$jf = Join-Path $ws "test.json" +'{"key":"value","nested":{"a":1}}' | Set-Content $jf + +T 'touch (new file)' { + $f = Join-Path $ws "touchtest.txt" + touch $f + if (-not (Test-Path $f)) { throw "file not created" } +} + +T 'touch (update timestamp)' { + $f = Join-Path $ws "touchtest.txt" + $before = (Get-Item $f).LastWriteTime + Start-Sleep -Milliseconds 100 + touch $f + $after = (Get-Item $f).LastWriteTime + if ($after -le $before) { throw "timestamp not updated" } +} + +T 'nf (alias for touch)' { + $f = Join-Path $ws "nftest.txt" + nf $f + if (-not (Test-Path $f)) { throw "file not created" } +} + +T 'ff (find files)' { + Set-Location $ws + $r = ff "testfile" + if (-not $r) { throw "no results" } + Set-Location $origDir +} + +T 'mkcd' { + $d = Join-Path $ws "mkcdtest" + mkcd $d + if ((Get-Location).Path -ne $d) { throw "not in expected dir: $(Get-Location)" } + Set-Location $origDir +} + +T 'head' { + $r = head $tf 5 + if (-not $r) { throw "no output" } +} + +T 'tail' { + $r = tail $tf 5 + if (-not $r) { throw "no output" } +} + +T 'sed' { + $sf = Join-Path $ws "sedtest.txt" + "foo bar baz" | Set-Content $sf + sed $sf "bar" "qux" + $c = Get-Content $sf -Raw + if ($c -notmatch "qux") { throw "replace failed: $c" } +} + +T 'which (pwsh)' { + $r = which pwsh + if (-not $r) { throw "no output" } +} + +T 'which (nonexistent)' { + Test-Throws { which "zzz_nonexistent_cmd_zzz" } "which should fail for a nonexistent command" +} + +T 'file (text)' { file $tf } +T 'file (json)' { file $jf } +T 'file (binary/exe)' { file "$env:SystemRoot\System32\cmd.exe" } +T 'file (directory)' { file $ws } + +T 'sizeof (file)' { + $r = sizeof $tf + if (-not $r) { throw "no output" } +} + +T 'sizeof (directory)' { + $r = sizeof $ws + if (-not $r) { throw "no output" } +} + +T 'export' { + export "PSP_RAW_TEST" "testval" + if ($env:PSP_RAW_TEST -ne "testval") { throw "env var not set" } + Remove-Item env:PSP_RAW_TEST -ErrorAction SilentlyContinue +} + +T 'bak' { + bak $tf + $baks = Get-ChildItem $ws -Filter "testfile.txt.*.bak" + if ($baks.Count -eq 0) { throw "no backup created" } +} + +T 'extract (.zip)' { + $zipDir = Join-Path $ws "ziptest" + New-Item -ItemType Directory $zipDir -Force | Out-Null + $zipSrc = Join-Path $zipDir "content.txt" + "zip content" | Set-Content $zipSrc + $zipPath = Join-Path $ws "test.zip" + Compress-Archive -Path $zipSrc -DestinationPath $zipPath -Force + $extractDir = Join-Path $ws "extracted" + New-Item -ItemType Directory $extractDir -Force | Out-Null + Set-Location $extractDir + extract $zipPath + $extracted = Get-ChildItem $extractDir -Recurse -File + if ($extracted.Count -eq 0) { throw "nothing extracted" } + Set-Location $origDir +} + +T 'trash' { + $trashFile = Join-Path $ws "trashme.txt" + "delete me" | Set-Content $trashFile + trash $trashFile + Start-Sleep -Milliseconds 500 + if (Test-Path $trashFile) { throw "file not moved to recycle bin" } +} + +# --- Navigation --- +Write-Host "`n--- Navigation ---" -ForegroundColor Magenta + +T 'docs' { + $before = Get-Location + docs + $after = Get-Location + Set-Location $before + if ($after.Path -notmatch 'Documents') { throw "not in Documents: $after" } +} + +T 'dtop' { + $before = Get-Location + dtop + $after = Get-Location + Set-Location $before + if ($after.Path -notmatch 'Desktop') { throw "not in Desktop: $after" } +} + +# --- Listing (eza/bat) --- +Write-Host "`n--- Listing ---" -ForegroundColor Magenta + +T 'ls' { & (Get-Command 'ls').Name $ws } +T 'la' { la $ws } +T 'll' { ll $ws } +T 'lt' { lt $ws } +T 'cat' { & (Get-Command 'cat').Name $tf } +T 'grep (in file)' { + Set-Location $ws + grep "hello" $ws + Set-Location $origDir +} + +# --- System --- +Write-Host "`n--- System ---" -ForegroundColor Magenta + +T 'uptime' { & (Get-Command 'uptime').Name } +T 'df' { df } +T 'path' { $r = path; if (-not $r) { throw "no output" } } +T 'env (no filter)' { env } +T 'env (filter)' { env "PATH" } +T 'svc (snapshot)' { svc -Count 5 } +T 'sysinfo' { sysinfo } +T 'eventlog' { & (Get-Command 'eventlog').Name 5 } +T 'pgrep' { $r = pgrep "pwsh"; if (-not $r) { throw "no pwsh process found" } } +T 'ports' { ports } + +# --- Network --- +Write-Host "`n--- Network ---" -ForegroundColor Magenta + +T 'pubip' { $r = pubip; if (-not $r -or $r.Trim().Length -lt 5) { throw "invalid IP: $r" } } +T 'localip' { localip } +T 'checkport (open)' { checkport "google.com" 443 } +T 'checkport (closed)' { checkport "localhost" 59999 } +T 'nslook (A)' { nslook "google.com" } +T 'nslook (MX)' { nslook "google.com" "MX" } +T 'nslook (TXT)' { nslook "google.com" "TXT" } +T 'tlscert' { tlscert "google.com" } +T 'portscan' { portscan "localhost" -Ports @(80, 443, 3389) } +T 'ipinfo' { ipinfo "8.8.8.8" } +T 'whois' { whois "google.com" } +T 'weather' { weather "Oslo" } +T 'http GET' { $r = http "https://httpbin.org/get"; if (-not $r) { throw "no response" } } +T 'http POST' { $r = http "https://httpbin.org/post" -Method POST -Body '{"test":true}'; if (-not $r) { throw "no response" } } +T 'hb' { hb $tf } + +# --- Crypto & Encoding --- +Write-Host "`n--- Crypto & Encoding ---" -ForegroundColor Magenta + +T 'hash (SHA256)' { + $r = hash $tf + if (-not $r -or $r.Length -ne 64) { throw "invalid hash: $r" } +} + +T 'hash (SHA512)' { + $r = hash $tf -Algorithm SHA512 + if (-not $r -or $r.Length -ne 128) { throw "invalid hash: $r" } +} + +T 'checksum (match)' { + $expected = hash $tf + checksum $tf $expected +} + +T 'checksum (mismatch)' { + checksum $tf "0000000000000000000000000000000000000000000000000000000000000000" +} + +T 'genpass (default)' { + $r = genpass + if (-not $r -or $r.Length -ne 20) { throw "invalid password length: $($r.Length)" } +} + +T 'genpass (custom length)' { + $r = genpass 50 + if (-not $r -or $r.Length -ne 50) { throw "invalid password length: $($r.Length)" } +} + +T 'b64 encode' { + $r = b64 "hello world" + if ($r -ne "aGVsbG8gd29ybGQ=") { throw "wrong encoding: $r" } +} + +T 'b64d decode' { + $r = b64d "aGVsbG8gd29ybGQ=" + if ($r -ne "hello world") { throw "wrong decoding: $r" } +} + +T 'b64d (invalid)' { + Test-Throws { b64d "not_valid_base64!!!" } "b64d should fail on invalid base64" +} + +T 'jwtd' { + jwtd "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" +} + +T 'uuid' { + uuid + $clip = Get-Clipboard + if (-not $clip -or $clip.Length -lt 30) { throw "uuid not on clipboard" } +} + +T 'epoch (now)' { + $r = epoch + if (-not $r -or $r -lt 1000000000) { throw "invalid epoch: $r" } +} + +T 'epoch (from timestamp)' { + $r = epoch 0 + if (-not $r) { throw "no output" } + if ($r.Year -ne 1970) { throw "wrong year: $($r.Year)" } +} + +T 'epoch (from date string)' { + $r = epoch "2000-01-01" + if (-not $r -or $r -lt 946000000) { throw "invalid epoch from date: $r" } +} + +T 'epoch (milliseconds)' { + $r = epoch 1516239022000 + if (-not $r) { throw "no output" } + if ($r.Year -ne 2018) { throw "wrong year for ms epoch: $($r.Year)" } +} + +T 'urlencode' { + $r = urlencode "hello world&foo=bar" + if ($r -ne "hello%20world%26foo%3Dbar") { throw "wrong encoding: $r" } +} + +T 'urldecode' { + $r = urldecode "hello%20world%26foo%3Dbar" + if ($r -ne "hello world&foo=bar") { throw "wrong decoding: $r" } +} + +# --- Developer --- +Write-Host "`n--- Developer ---" -ForegroundColor Magenta + +T 'prettyjson (file)' { + $r = prettyjson $jf + if (-not $r) { throw "no output" } +} + +T 'prettyjson (invalid json)' { + $badjson = Join-Path $ws "bad.json" + "not json {{{" | Set-Content $badjson + Test-Throws { prettyjson $badjson } "prettyjson should fail on invalid JSON" +} + +T 'timer' { + timer { Start-Sleep -Milliseconds 50 } +} + +# --- Git (temp repo) --- +Write-Host "`n--- Git ---" -ForegroundColor Magenta + +$gitDir = Join-Path $ws "gitrepo" +New-Item -ItemType Directory $gitDir -Force | Out-Null +Set-Location $gitDir +git init 2>&1 | Out-Null +git config user.email "test@test.com" 2>&1 | Out-Null +git config user.name "Test" 2>&1 | Out-Null +"initial" | Set-Content (Join-Path $gitDir "readme.md") +git add . 2>&1 | Out-Null +git commit -m "init" 2>&1 | Out-Null + +T 'gs' { gs } + +T 'ga' { + "change" | Set-Content (Join-Path $gitDir "newfile.txt") + ga + $status = git status --porcelain 2>&1 + if ($status -match '^\?\?') { throw "unstaged files remain" } +} + +T 'gc (commit)' { + & (Get-Command 'gc' -CommandType Function).Name "test commit message" + $log = git log --oneline -1 2>&1 + if ($log -notmatch "test commit") { throw "commit not found: $log" } +} + +T 'gcom' { + "another" | Set-Content (Join-Path $gitDir "another.txt") + gcom "gcom test" + $log = git log --oneline -1 2>&1 + if ($log -notmatch "gcom test") { throw "gcom commit not found: $log" } +} + +Set-Location $origDir + +# --- Clipboard --- +Write-Host "`n--- Clipboard ---" -ForegroundColor Magenta + +T 'cpy' { + cpy "raw-test-clipboard-value" +} + +T 'pst' { + $r = pst + if ($r -ne "raw-test-clipboard-value") { throw "clipboard mismatch: expected 'raw-test-clipboard-value', got '$r'" } +} + +# --- Docker (if available) --- +$hasDocker = [bool](Get-Command docker -ErrorAction SilentlyContinue) +$dockerRunning = $false +if ($hasDocker) { $null = docker info 2>&1; $dockerRunning = ($LASTEXITCODE -eq 0) } +if ($dockerRunning) { + Write-Host "`n--- Docker ---" -ForegroundColor Magenta + T 'dps' { dps } + T 'dpa' { dpa } + T 'dimg' { dimg } +} + +# ##################################################################### +# PART 2: CACHING +# ##################################################################### +Write-Host "`n--- Caching: OMP & zoxide init ---" -ForegroundColor Magenta + +$realCacheDir = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' + +T 'Cache dir exists' { + if (-not (Test-Path $realCacheDir)) { throw "Cache dir missing: $realCacheDir" } +} + +T 'theme.json cached' { + $f = Join-Path $realCacheDir 'theme.json' + if (-not (Test-Path $f)) { throw "theme.json not in cache" } + $j = Get-Content $f -Raw | ConvertFrom-Json + if (-not $j.theme.name) { throw "theme.name missing" } + if (-not $j.theme.url) { throw "theme.url missing" } +} + +T 'terminal-config.json cached' { + $f = Join-Path $realCacheDir 'terminal-config.json' + if (-not (Test-Path $f)) { throw "terminal-config.json not in cache" } + $j = Get-Content $f -Raw | ConvertFrom-Json + if (-not $j.defaults) { throw "defaults missing" } + if (-not $j.fontInstall) { throw "fontInstall missing" } +} + +T 'user-settings.json exists' { + $f = Join-Path $realCacheDir 'user-settings.json' + if (-not (Test-Path $f)) { throw "user-settings.json not found" } + $j = Get-Content $f -Raw | ConvertFrom-Json + if (-not $j._comment) { throw "template _comment missing" } +} + +T 'OMP theme file in cache' { + $themes = Get-ChildItem $realCacheDir -Filter '*.omp.json' -ErrorAction SilentlyContinue + if (-not $themes -or $themes.Count -eq 0) { throw "No OMP theme file in cache" } + $j = Get-Content $themes[0].FullName -Raw | ConvertFrom-Json + if (-not $j) { throw "Theme file is not valid JSON" } +} + +T 'OMP init cache format' { + $f = Join-Path $realCacheDir 'omp-init.ps1' + if (-not (Test-Path $f)) { throw "omp-init.ps1 not in cache" } + $size = (Get-Item $f).Length + if ($size -eq 0) { throw "omp-init.ps1 is 0 bytes (corrupt)" } + $header = Get-Content $f -First 1 + if ($header -notmatch '^# OMP_CACHE:') { throw "Invalid header: $header" } + if ($header -notmatch '\|') { throw "Header missing pipe separator (no theme path): $header" } +} + +T 'zoxide init cache format' { + $f = Join-Path $realCacheDir 'zoxide-init.ps1' + if (-not (Test-Path $f)) { throw "zoxide-init.ps1 not in cache" } + $size = (Get-Item $f).Length + if ($size -eq 0) { throw "zoxide-init.ps1 is 0 bytes (corrupt)" } + $header = Get-Content $f -First 1 + if ($header -notmatch '^# ZOXIDE_CACHE_VERSION:') { throw "Invalid header: $header" } +} + +# --- Cache corruption recovery --- +Write-Host "`n--- Cache corruption recovery ---" -ForegroundColor Magenta + +T 'OMP cache: 0-byte recovery' { + $f = Join-Path $ws 'omp-init.ps1' + New-Item $f -ItemType File -Force | Out-Null + $size = (Get-Item $f).Length + if ($size -ne 0) { throw "Setup failed - file not 0 bytes" } + $fileSize = (Get-Item $f).Length + $cacheValid = $false + if ($fileSize -gt 0) { + $cacheContent = Get-Content $f -First 1 + if ($cacheContent -eq '# OMP_CACHE: test | test') { $cacheValid = $true } + } + if ($cacheValid) { throw "0-byte file was treated as valid cache" } +} + +T 'OMP cache: wrong version invalidation' { + $f = Join-Path $ws 'omp-init.ps1' + $utf8 = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($f, "# OMP_CACHE: old_version | old_path`nWrite-Host test", $utf8) + $header = Get-Content $f -First 1 + $expected = '# OMP_CACHE: current_version | current_path' + $cacheValid = ($header -eq $expected) + if ($cacheValid) { throw "Wrong version was accepted as valid" } +} + +T 'zoxide cache: wrong version invalidation' { + $f = Join-Path $ws 'zoxide-init.ps1' + $utf8 = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($f, "# ZOXIDE_CACHE_VERSION: old_ver`nWrite-Host test", $utf8) + $header = Get-Content $f -First 1 + $expected = '# ZOXIDE_CACHE_VERSION: current_ver' + $cacheValid = ($header -eq $expected) + if ($cacheValid) { throw "Wrong version was accepted as valid" } +} + +T 'Corrupt JSON config recovery' { + $f = Join-Path $ws 'theme.json' + $utf8 = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($f, "{ this is not valid json !!!", $utf8) + $recovered = $false + try { + $null = Get-Content $f -Raw | ConvertFrom-Json + } + catch { + Remove-Item $f -Force -ErrorAction SilentlyContinue + $recovered = $true + } + if (-not $recovered) { throw "Corrupt JSON was not caught" } +} + +# ##################################################################### +# PART 3: CONFIG MERGE +# ##################################################################### +Write-Host "`n--- Config merge (Merge-JsonObject) ---" -ForegroundColor Magenta + +T 'Merge-JsonObject: flat override' { + $base = [PSCustomObject]@{ a = 1; b = 2 } + Merge-JsonObject $base ([PSCustomObject]@{ b = 99; c = 3 }) + if ($base.a -ne 1) { throw "a changed: $($base.a)" } + if ($base.b -ne 99) { throw "b not overridden: $($base.b)" } + if ($base.c -ne 3) { throw "c not added: $($base.c)" } +} + +T 'Merge-JsonObject: deep nested' { + $base = [PSCustomObject]@{ font = [PSCustomObject]@{ face = 'Consolas'; size = 11 }; opacity = 75 } + Merge-JsonObject $base ([PSCustomObject]@{ font = [PSCustomObject]@{ size = 14 } }) + if ($base.font.face -ne 'Consolas') { throw "face lost: $($base.font.face)" } + if ($base.font.size -ne 14) { throw "size not overridden: $($base.font.size)" } + if ($base.opacity -ne 75) { throw "opacity changed: $($base.opacity)" } +} + +T 'Merge-JsonObject: replace scalar with object' { + $base = [PSCustomObject]@{ theme = 'simple' } + Merge-JsonObject $base ([PSCustomObject]@{ theme = [PSCustomObject]@{ name = 'pure' } }) + if ($base.theme.name -ne 'pure') { throw "replacement failed" } +} + +T 'Merge-JsonObject: add to empty base' { + $base = [PSCustomObject]@{} + Merge-JsonObject $base ([PSCustomObject]@{ a = 1; b = [PSCustomObject]@{ x = 10 } }) + if ($base.a -ne 1) { throw "a not added" } + if ($base.b.x -ne 10) { throw "nested not added" } +} + +T 'Merge-JsonObject: null base property' { + $base = [PSCustomObject]@{ a = 1 } + Merge-JsonObject $base ([PSCustomObject]@{ b = [PSCustomObject]@{ x = 1 } }) + if ($base.b.x -ne 1) { throw "merge into missing property failed" } +} + +T 'Config merge precedence: terminal-config < user-settings < theme' { + $terminalConfig = '{"defaults":{"opacity":75,"font":{"face":"CaskaydiaCove NF","size":11}}}' | ConvertFrom-Json + $userSettings = '{"defaults":{"opacity":90,"font":{"size":14}}}' | ConvertFrom-Json + Merge-JsonObject $terminalConfig.defaults $userSettings.defaults + if ($terminalConfig.defaults.opacity -ne 90) { throw "user opacity not applied: $($terminalConfig.defaults.opacity)" } + if ($terminalConfig.defaults.font.size -ne 14) { throw "user font size not applied: $($terminalConfig.defaults.font.size)" } + if ($terminalConfig.defaults.font.face -ne 'CaskaydiaCove NF') { throw "font face lost: $($terminalConfig.defaults.font.face)" } +} + +T 'Config merge: keybindings append (not replace)' { + $terminalConfig = '{"keybindings":[{"keys":"ctrl+a","command":"selectAll"}]}' | ConvertFrom-Json + $userSettings = '{"keybindings":[{"keys":"ctrl+b","command":"copy"}]}' | ConvertFrom-Json + $terminalConfig.keybindings = @($terminalConfig.keybindings) + @($userSettings.keybindings) + if ($terminalConfig.keybindings.Count -ne 2) { throw "expected 2 keybindings, got $($terminalConfig.keybindings.Count)" } + if ($terminalConfig.keybindings[0].keys -ne 'ctrl+a') { throw "original lost" } + if ($terminalConfig.keybindings[1].keys -ne 'ctrl+b') { throw "user binding not appended" } +} + +# ##################################################################### +# PART 4: WINDOWS TERMINAL SETTINGS +# ##################################################################### +Write-Host "`n--- Windows Terminal settings ---" -ForegroundColor Magenta + +$wtSettingsPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' +$hasWT = Test-Path $wtSettingsPath + +if ($hasWT) { + T 'WT settings: can parse real file' { + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $raw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncPattern, '' + $wt = $raw | ConvertFrom-Json + if (-not $wt.profiles) { throw "profiles missing" } + } + + T 'WT settings: backups exist' { + $wtDir = Split-Path $wtSettingsPath + $baks = Get-ChildItem $wtDir -Filter 'settings.json.*.bak' -ErrorAction SilentlyContinue + if (-not $baks -or $baks.Count -eq 0) { throw "No WT backups found" } + } + + T 'WT settings: backup count <= 5' { + $wtDir = Split-Path $wtSettingsPath + $baks = Get-ChildItem $wtDir -Filter 'settings.json.*.bak' -ErrorAction SilentlyContinue + if ($baks.Count -gt 5) { throw "Too many backups: $($baks.Count) (max 5)" } + } + + T 'WT settings: defaults have expected keys' { + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $raw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncPattern, '' + $wt = $raw | ConvertFrom-Json + $d = $wt.profiles.defaults + if (-not $d) { throw "profiles.defaults missing" } + if (-not $d.font) { throw "font missing" } + if (-not $d.font.face) { throw "font.face missing" } + if ($null -eq $d.opacity) { throw "opacity missing" } + if (-not $d.colorScheme) { throw "colorScheme missing" } + } + + T 'WT settings: color scheme installed' { + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $raw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncPattern, '' + $wt = $raw | ConvertFrom-Json + $schemeName = $wt.profiles.defaults.colorScheme + if (-not $schemeName) { throw "no colorScheme set" } + $scheme = $wt.schemes | Where-Object { $_.name -eq $schemeName } + if (-not $scheme) { throw "Scheme '$schemeName' not in schemes array" } + } + + T 'WT settings: keybinding upsert format' { + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $raw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncPattern, '' + $wt = $raw | ConvertFrom-Json + $hasNewFormat = $null -ne $wt.PSObject.Properties['keybindings'] + if ($hasNewFormat) { + $kb = $wt.keybindings | Where-Object { $_.keys -eq 'ctrl+a' } + if (-not $kb) { throw "ctrl+a not in keybindings array" } + if (-not $kb.id) { throw "keybinding missing id" } + $action = $wt.actions | Where-Object { $_.id -eq $kb.id } + if (-not $action) { throw "No action for id '$($kb.id)'" } + } else { + $action = $wt.actions | Where-Object { $_.keys -eq 'ctrl+a' } + if (-not $action) { throw "ctrl+a not in actions (old format)" } + } + } + + T 'WT settings: -NoLogo on PowerShell profiles' { + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $raw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncPattern, '' + $wt = $raw | ConvertFrom-Json + if ($wt.profiles.list) { + $pwshProfiles = @($wt.profiles.list | Where-Object { + $cmd = if ($_.commandline) { $_.commandline } else { '' } + $src = if ($_.source) { $_.source } else { '' } + ($cmd -match 'pwsh' -or $src -match 'PowerShellCore' -or $cmd -match 'powershell\.exe' -or $_.name -match 'Windows PowerShell') + }) + foreach ($p in $pwshProfiles) { + $cmd = if ($p.commandline) { $p.commandline } else { '' } + if ($cmd -match '(?i)-(Command|File|EncodedCommand)') { continue } + if ($cmd -and $cmd -notmatch '-NoLogo') { + throw "Profile '$($p.name)' missing -NoLogo: $cmd" + } + } + } + } + + T 'WT merge: sandbox roundtrip' { + $mockWt = [PSCustomObject]@{ + profiles = [PSCustomObject]@{ + defaults = [PSCustomObject]@{ font = [PSCustomObject]@{ face = 'Consolas'; size = 10 } } + list = @() + } + schemes = @() + actions = @() + } + $theme = Get-Content (Join-Path $repoRoot 'theme.json') -Raw | ConvertFrom-Json + $tc = Get-Content (Join-Path $repoRoot 'terminal-config.json') -Raw | ConvertFrom-Json + $d = $mockWt.profiles.defaults + $tc.defaults.PSObject.Properties | ForEach-Object { + $d | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value -Force + } + if ($theme.windowsTerminal.colorScheme) { + $d | Add-Member -NotePropertyName 'colorScheme' -NotePropertyValue $theme.windowsTerminal.colorScheme -Force + } + $schemeDef = $theme.windowsTerminal.scheme + $mockWt.schemes = @(@($mockWt.schemes | Where-Object { $_ -and $_.name -ne $schemeDef.name }) + ([PSCustomObject]$schemeDef)) + foreach ($kb in $tc.keybindings) { + $mockWt.actions = @($mockWt.actions | Where-Object { $_ -and $_.keys -ne $kb.keys }) + $mockWt.actions = @($mockWt.actions) + ([PSCustomObject]@{ keys = $kb.keys; command = $kb.command }) + } + $json = $mockWt | ConvertTo-Json -Depth 100 + $parsed = $json | ConvertFrom-Json + if ($parsed.profiles.defaults.font.face -ne 'CaskaydiaCove NF') { throw "font.face wrong" } + if ($parsed.profiles.defaults.opacity -ne 75) { throw "opacity wrong" } + if ($parsed.profiles.defaults.colorScheme -ne 'Tokyo Night') { throw "colorScheme wrong" } + if ($parsed.schemes.Count -ne 1) { throw "expected 1 scheme" } + if ($parsed.schemes[0].name -ne 'Tokyo Night') { throw "scheme name wrong" } + } +} else { + Write-Host " SKIP Windows Terminal not installed" -ForegroundColor DarkGray +} + +# ##################################################################### +# PART 5: SETUP.PS1 FUNCTIONS +# ##################################################################### +Write-Host "`n--- setup.ps1 functions ---" -ForegroundColor Magenta + +T 'setup.ps1 parses without errors' { + if ($parseErrors2.Count -gt 0) { throw "$($parseErrors2.Count) parse error(s)" } +} + +T 'Test-InternetConnection' { + $r = Test-InternetConnection + if ($r -ne $true) { throw "Expected true, got $r" } +} + +T 'Invoke-DownloadWithRetry (real download)' { + $tmp = Join-Path $ws 'dl-test.json' + Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/theme.json' -OutFile $tmp + if (-not (Test-Path $tmp) -or (Get-Item $tmp).Length -eq 0) { throw 'download empty' } + $j = Get-Content $tmp -Raw | ConvertFrom-Json + if (-not $j.theme.name) { throw "downloaded theme.json has no theme.name" } +} + +T 'Invoke-DownloadWithRetry (bad URL fails)' { + $tmp = Join-Path $ws 'bad-dl.json' + Test-Throws { + Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/NONEXISTENT.json' -OutFile $tmp -MaxAttempts 1 + } "Invoke-DownloadWithRetry should fail on 404" + if (Test-Path $tmp) { throw "Corrupt file not cleaned up" } +} + +T 'Install-WingetPackage (already installed)' { + $r = Install-WingetPackage -Name 'PowerShell' -Id 'Microsoft.PowerShell' + if ($r -ne $true) { throw "Expected true, got $r" } +} + +T 'Install-NerdFonts (detection only)' { + $r = Install-NerdFonts + if ($r -ne $true) { throw "Expected true, got $r" } +} + +T 'Install-OhMyPoshTheme (real download)' { + $tmpCache = Join-Path $ws 'omp-cache' + New-Item -ItemType Directory $tmpCache -Force | Out-Null + $origConfigCache = $configCachePath + $script:configCachePath = $tmpCache + try { + $r = Install-OhMyPoshTheme -ThemeName 'test-theme' -ThemeUrl 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/agnoster.omp.json' + if ($r -ne $true) { throw "Expected true, got $r" } + $themeFile = Join-Path $tmpCache 'test-theme.omp.json' + if (-not (Test-Path $themeFile)) { throw "Theme file not written" } + $j = Get-Content $themeFile -Raw | ConvertFrom-Json + if (-not $j) { throw "Theme file not valid JSON" } + } + finally { $script:configCachePath = $origConfigCache } +} + +T 'EditorCandidates valid' { + if (-not $EditorCandidates -or $EditorCandidates.Count -lt 5) { throw "Only $($EditorCandidates.Count) candidates" } + foreach ($ed in $EditorCandidates) { + if (-not $ed.Cmd -or -not $ed.Display) { throw "Invalid: $($ed | ConvertTo-Json -Compress)" } + } +} + +T 'Merge-JsonObject from setup.ps1' { + $b = [PSCustomObject]@{ x = 1; n = [PSCustomObject]@{ a = 10 } } + Merge-JsonObject $b ([PSCustomObject]@{ x = 99; n = [PSCustomObject]@{ b = 20 } }) + if ($b.x -ne 99 -or $b.n.a -ne 10 -or $b.n.b -ne 20) { throw "merge mismatch" } +} + +# ##################################################################### +# PART 6: UNINSTALL-PROFILE +# ##################################################################### +Write-Host "`n--- Uninstall-Profile (-WhatIf) ---" -ForegroundColor Magenta + +T 'Uninstall-Profile -WhatIf (no changes)' { + $before = @{ Profile = Test-Path $PROFILE; Cache = Test-Path $realCacheDir } + Uninstall-Profile -WhatIf -Confirm:$false + $after = @{ Profile = Test-Path $PROFILE; Cache = Test-Path $realCacheDir } + if ($before.Profile -ne $after.Profile) { throw "Profile changed during -WhatIf!" } + if ($before.Cache -ne $after.Cache) { throw "Cache changed during -WhatIf!" } +} + +T 'Uninstall-Profile -All -WhatIf (no changes)' { + $before = @{ Profile = Test-Path $PROFILE; Cache = Test-Path $realCacheDir } + Uninstall-Profile -All -WhatIf -Confirm:$false + $after = @{ Profile = Test-Path $PROFILE; Cache = Test-Path $realCacheDir } + if ($before.Profile -ne $after.Profile) { throw "Profile changed during -All -WhatIf!" } + if ($before.Cache -ne $after.Cache) { throw "Cache changed during -All -WhatIf!" } +} + +Write-Host "`n--- Uninstall sandbox (isolated) ---" -ForegroundColor Magenta + +T 'Uninstall sandbox: core cleanup' { + $sandboxScript = Join-Path $ws 'uninstall-sandbox.ps1' + $utf8 = [System.Text.UTF8Encoding]::new($false) + $code = @' +param($ProfileSource) +$ErrorActionPreference = 'Stop' + +$sb = Join-Path $env:TEMP "psp-uninst-$([System.IO.Path]::GetRandomFileName())" +$docsRoot = Join-Path $sb 'Documents' +$cacheDir = Join-Path $sb 'PowerShellProfile' +$ps7Dir = Join-Path $docsRoot 'PowerShell' +$ps5Dir = Join-Path $docsRoot 'WindowsPowerShell' +New-Item -ItemType Directory -Path $cacheDir, $ps7Dir, $ps5Dir -Force | Out-Null + +$utf8 = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText((Join-Path $cacheDir 'theme.json'), '{"theme":{"name":"test"}}', $utf8) +[System.IO.File]::WriteAllText((Join-Path $cacheDir 'terminal-config.json'), '{"defaults":{}}', $utf8) +[System.IO.File]::WriteAllText((Join-Path $cacheDir 'omp-init.ps1'), '# cache', $utf8) +[System.IO.File]::WriteAllText((Join-Path $cacheDir 'zoxide-init.ps1'), '# cache', $utf8) +[System.IO.File]::WriteAllText((Join-Path $cacheDir 'test.omp.json'), '{}', $utf8) +[System.IO.File]::WriteAllText((Join-Path $cacheDir 'user-settings.json'), '{"_comment":"test"}', $utf8) +Copy-Item $ProfileSource (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1') +Copy-Item $ProfileSource (Join-Path $ps5Dir 'Microsoft.PowerShell_profile.ps1') +[System.IO.File]::WriteAllText((Join-Path $ps7Dir 'profile_user.ps1'), '# user', $utf8) +[System.IO.File]::WriteAllText((Join-Path $ps5Dir 'profile_user.ps1'), '# user', $utf8) + +$origLocal = $env:LOCALAPPDATA +$origProfile = $PROFILE +$env:LOCALAPPDATA = $sb +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$env:CI = 'true' +. $ProfileSource +Remove-Item env:CI + +try { + Uninstall-Profile -Confirm:$false + + $errors = @() + if (Test-Path (Join-Path $cacheDir 'theme.json')) { $errors += 'theme.json not removed' } + if (Test-Path (Join-Path $cacheDir 'omp-init.ps1')) { $errors += 'omp-init.ps1 not removed' } + if (Test-Path (Join-Path $cacheDir 'zoxide-init.ps1')) { $errors += 'zoxide-init.ps1 not removed' } + if (Test-Path (Join-Path $cacheDir 'test.omp.json')) { $errors += 'test.omp.json not removed' } + if (-not (Test-Path (Join-Path $cacheDir 'user-settings.json'))) { $errors += 'user-settings.json removed without -RemoveUserData!' } + if (Test-Path (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1')) { $errors += 'PS7 profile not removed' } + if (Test-Path (Join-Path $ps5Dir 'Microsoft.PowerShell_profile.ps1')) { $errors += 'PS5 profile not removed' } + if (-not (Test-Path (Join-Path $ps7Dir 'profile_user.ps1'))) { $errors += 'PS7 profile_user.ps1 removed without -RemoveUserData!' } + if (-not (Test-Path (Join-Path $ps5Dir 'profile_user.ps1'))) { $errors += 'PS5 profile_user.ps1 removed without -RemoveUserData!' } + + if ($errors.Count -gt 0) { + foreach ($e in $errors) { Write-Host " ASSERT: $e" -ForegroundColor Red } + exit 1 + } +} +finally { + $env:LOCALAPPDATA = $origLocal + $global:PROFILE = $origProfile + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} +'@ + [System.IO.File]::WriteAllText($sandboxScript, $code, $utf8) + $output = pwsh -NonInteractive -NoProfile -File $sandboxScript -ProfileSource $profilePath 2>&1 + foreach ($line in $output) { + if ($line -match 'ASSERT:') { Write-Host " $line" -ForegroundColor Red } + } + if ($LASTEXITCODE -ne 0) { + $asserts = @($output | Where-Object { $_ -match 'ASSERT:' }) + throw "Uninstall sandbox failed: $($asserts -join '; ')" + } +} + +T 'Uninstall sandbox: -RemoveUserData' { + $sandboxScript = Join-Path $ws 'uninstall-userdata.ps1' + $utf8 = [System.Text.UTF8Encoding]::new($false) + $code = @' +param($ProfileSource) +$ErrorActionPreference = 'Stop' + +$sb = Join-Path $env:TEMP "psp-uninst2-$([System.IO.Path]::GetRandomFileName())" +$docsRoot = Join-Path $sb 'Documents' +$cacheDir = Join-Path $sb 'PowerShellProfile' +$ps7Dir = Join-Path $docsRoot 'PowerShell' +$ps5Dir = Join-Path $docsRoot 'WindowsPowerShell' +New-Item -ItemType Directory -Path $cacheDir, $ps7Dir, $ps5Dir -Force | Out-Null + +$utf8 = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText((Join-Path $cacheDir 'user-settings.json'), '{"_comment":"test"}', $utf8) +Copy-Item $ProfileSource (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1') +[System.IO.File]::WriteAllText((Join-Path $ps7Dir 'profile_user.ps1'), '# user', $utf8) +[System.IO.File]::WriteAllText((Join-Path $ps5Dir 'profile_user.ps1'), '# user', $utf8) + +$origLocal = $env:LOCALAPPDATA +$origProfile = $PROFILE +$env:LOCALAPPDATA = $sb +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$env:CI = 'true' +. $ProfileSource +Remove-Item env:CI + +try { + Uninstall-Profile -RemoveUserData -Confirm:$false + + $errors = @() + if (Test-Path (Join-Path $cacheDir 'user-settings.json')) { $errors += 'user-settings.json not removed' } + if (Test-Path (Join-Path $ps7Dir 'profile_user.ps1')) { $errors += 'PS7 profile_user.ps1 not removed' } + if (Test-Path (Join-Path $ps5Dir 'profile_user.ps1')) { $errors += 'PS5 profile_user.ps1 not removed' } + + if ($errors.Count -gt 0) { + foreach ($e in $errors) { Write-Host " ASSERT: $e" -ForegroundColor Red } + exit 1 + } +} +finally { + $env:LOCALAPPDATA = $origLocal + $global:PROFILE = $origProfile + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} +'@ + [System.IO.File]::WriteAllText($sandboxScript, $code, $utf8) + $output = pwsh -NonInteractive -NoProfile -File $sandboxScript -ProfileSource $profilePath 2>&1 + foreach ($line in $output) { + if ($line -match 'ASSERT:') { Write-Host " $line" -ForegroundColor Red } + } + if ($LASTEXITCODE -ne 0) { + $asserts = @($output | Where-Object { $_ -match 'ASSERT:' }) + throw "-RemoveUserData sandbox failed: $($asserts -join '; ')" + } +} + +T 'Uninstall sandbox: WT backup restore' { + $sandboxScript = Join-Path $ws 'uninstall-wt.ps1' + $utf8 = [System.Text.UTF8Encoding]::new($false) + $code = @' +param($ProfileSource) +$ErrorActionPreference = 'Stop' + +$sb = Join-Path $env:TEMP "psp-uninst3-$([System.IO.Path]::GetRandomFileName())" +$docsRoot = Join-Path $sb 'Documents' +$cacheDir = Join-Path $sb 'PowerShellProfile' +$ps7Dir = Join-Path $docsRoot 'PowerShell' +$wtDir = Join-Path $sb 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' +New-Item -ItemType Directory -Path $cacheDir, $ps7Dir, $wtDir -Force | Out-Null + +$utf8 = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText((Join-Path $wtDir 'settings.json'), '{"profiles":{"defaults":{"modified":true}}}', $utf8) +[System.IO.File]::WriteAllText((Join-Path $wtDir 'settings.json.20250101-000000.bak'), '{"profiles":{"defaults":{"original_old":true}}}', $utf8) +Start-Sleep -Milliseconds 50 +[System.IO.File]::WriteAllText((Join-Path $wtDir 'settings.json.20260101-000000.bak'), '{"profiles":{"defaults":{"original_new":true}}}', $utf8) + +Copy-Item $ProfileSource (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1') + +$origLocal = $env:LOCALAPPDATA +$origProfile = $PROFILE +$env:LOCALAPPDATA = $sb +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$env:CI = 'true' +. $ProfileSource +Remove-Item env:CI + +try { + Uninstall-Profile -Confirm:$false + + $errors = @() + $wtSettings = Join-Path $wtDir 'settings.json' + if (-not (Test-Path $wtSettings)) { $errors += 'WT settings.json deleted instead of restored!' } + else { + $content = Get-Content $wtSettings -Raw | ConvertFrom-Json + if (-not $content.profiles.defaults.original_new) { $errors += "WT not restored from newest backup" } + } + $remainingBaks = Get-ChildItem $wtDir -Filter 'settings.json.*.bak' -ErrorAction SilentlyContinue + if ($remainingBaks.Count -gt 0) { $errors += "WT backups not cleaned up ($($remainingBaks.Count) remaining)" } + + if ($errors.Count -gt 0) { + foreach ($e in $errors) { Write-Host " ASSERT: $e" -ForegroundColor Red } + exit 1 + } +} +finally { + $env:LOCALAPPDATA = $origLocal + $global:PROFILE = $origProfile + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} +'@ + [System.IO.File]::WriteAllText($sandboxScript, $code, $utf8) + $output = pwsh -NonInteractive -NoProfile -File $sandboxScript -ProfileSource $profilePath 2>&1 + foreach ($line in $output) { + if ($line -match 'ASSERT:') { Write-Host " $line" -ForegroundColor Red } + } + if ($LASTEXITCODE -ne 0) { + $asserts = @($output | Where-Object { $_ -match 'ASSERT:' }) + throw "WT restore sandbox failed: $($asserts -join '; ')" + } +} + +# ##################################################################### +# PART 7: PROFILE LOAD & INIT +# ##################################################################### +Write-Host "`n--- Profile load & init ---" -ForegroundColor Magenta + +T 'Profile loads without errors (CI mode)' { + $output = pwsh -NonInteractive -NoProfile -Command "`$env:CI = 'true'; . '$profilePath'" 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Profile load failed: $($output -join '; ')" } +} + +T 'ProfileTools has 6 tools' { + if ($script:ProfileTools.Count -ne 6) { throw "Expected 6, got $($script:ProfileTools.Count)" } +} + +T 'ProfileTools: all have required keys' { + foreach ($tool in $script:ProfileTools) { + if (-not $tool.Name) { throw "Missing Name" } + if (-not $tool.Id) { throw "Missing Id for $($tool.Name)" } + if (-not $tool.Cmd) { throw "Missing Cmd for $($tool.Name)" } + if (-not $tool.VerCmd) { throw "Missing VerCmd for $($tool.Name)" } + } +} + +T 'All 6 tools installed and accessible' { + $missing = @() + foreach ($tool in $script:ProfileTools) { + if (-not (Get-Command $tool.Cmd -ErrorAction SilentlyContinue)) { $missing += $tool.Name } + } + if ($missing.Count -gt 0) { throw "Missing: $($missing -join ', ')" } +} + +T 'All tools report valid version' { + foreach ($tool in $script:ProfileTools) { + $cmd = Get-Command $tool.Cmd -ErrorAction SilentlyContinue + if ($cmd) { + $ver = & $tool.Cmd $tool.VerCmd 2>&1 | Out-String + $verLine = ($ver.Trim().Split([char]10) | Select-Object -First 1).Trim() + if (-not $verLine -or $verLine.Length -lt 3) { throw "$($tool.Name) version too short: '$verLine'" } + } + } +} + +T 'profile_user.ps1 exists' { + $f = Join-Path (Split-Path $PROFILE) 'profile_user.ps1' + if (-not (Test-Path $f)) { throw "not found at $f" } +} + +T 'profile_user.ps1 has EditorPriority' { + $f = Join-Path (Split-Path $PROFILE) 'profile_user.ps1' + $content = Get-Content $f -Raw + if ($content -notmatch 'EditorPriority') { throw "No EditorPriority" } +} + +T 'Nerd Font installed' { + [void][System.Reflection.Assembly]::LoadWithPartialName('System.Drawing') + $fc = New-Object System.Drawing.Text.InstalledFontCollection + $nf = @($fc.Families | Where-Object { $_.Name -match 'Caskaydia|NF|Nerd' }) + $fc.Dispose() + if ($nf.Count -eq 0) { throw "No Nerd Font detected" } +} + +T 'PSFzf module available' { + if (-not (Get-Module -ListAvailable -Name PSFzf)) { throw "PSFzf not installed" } +} + +T 'Resolve-PreferredEditor' { + $r = Resolve-PreferredEditor + if (-not $r) { throw "no editor resolved" } +} + +T 'Get-SystemBootTime' { + $r = Get-SystemBootTime + if (-not $r) { throw "no boot time" } + if ($r -gt (Get-Date)) { throw "boot time is in the future: $r" } +} + +# ##################################################################### +# PART 8: EDGE CASES +# ##################################################################### +Write-Host "`n--- Edge cases ---" -ForegroundColor Magenta + +T 'touch (no args)' { + Test-Throws { touch } "touch should require a path" +} + +T 'head (no args)' { + Test-Throws { head } "head should require a path" +} + +T 'tail (no args)' { + Test-Throws { tail } "tail should require a path" +} + +T 'sed (nonexistent file)' { + Test-Throws { sed "C:\nonexistent\file.txt" "a" "b" } "sed should fail for a nonexistent file" +} + +T 'sed (empty find)' { + Test-Throws { sed $tf "" "b" } "sed should require a non-empty find value" +} + +T 'mkcd (no args)' { + Test-Throws { mkcd } "mkcd should require a directory name" +} + +T 'extract (nonexistent)' { + Test-Throws { extract "C:\nonexistent\file.zip" } "extract should fail for a nonexistent archive" +} + +T 'extract (unsupported format)' { + $unsup = Join-Path $ws "test.xyz" + "data" | Set-Content $unsup + Test-Throws { extract $unsup } "extract should fail for unsupported archive types" +} + +T 'hash (nonexistent file)' { + Test-Throws { hash "C:\nonexistent\file.bin" } "hash should fail for a nonexistent file" +} + +T 'sizeof (nonexistent)' { + Test-Throws { sizeof "C:\nonexistent\path" } "sizeof should fail for a nonexistent path" +} + +T 'checksum (nonexistent file)' { + Test-Throws { checksum "C:\nonexistent\file.bin" "abc" } "checksum should fail for a nonexistent file" +} + +T 'file (nonexistent)' { + Test-Throws { file "C:\nonexistent\file.bin" } "file should fail for a nonexistent file" +} + +T 'cpy (no args)' { + Test-Throws { cpy } "cpy should require input text" +} + +T 'gc (no args)' { + Test-Throws { & (Get-Command 'gc' -CommandType Function).Name } "gc should require a commit message" +} + +T 'gcom (no args)' { + Test-Throws { gcom } "gcom should require a commit message" +} + +T 'lazyg (no args)' { + Test-Throws { lazyg } "lazyg should require a commit message" +} + +T 'genpass (min length)' { + $r = genpass 1 + if ($r.Length -ne 1) { throw "expected length 1, got $($r.Length)" } +} + +T 'epoch (negative)' { + $r = epoch -100 + if (-not $r) { throw "no output for negative epoch" } +} + +T 'sed (null replace = delete)' { + $sf = Join-Path $ws "sed-null.txt" + "remove_this_word here" | Set-Content $sf + sed $sf "remove_this_word " $null + $c = Get-Content $sf -Raw + if ($c -match "remove_this_word") { throw "word not removed: $c" } +} + +T 'checksum (auto-detect SHA384 by length)' { + $sha384 = hash $tf -Algorithm SHA384 + checksum $tf $sha384 +} + +T 'checksum (auto-detect SHA512 by length)' { + $sha512 = hash $tf -Algorithm SHA512 + checksum $tf $sha512 +} + +T 'file (empty file)' { + $ef = Join-Path $ws "empty.txt" + New-Item $ef -ItemType File -Force | Out-Null + file $ef +} + +T 'file (PNG magic bytes)' { + $png = Join-Path $ws "fake.png" + [byte[]]$bytes = @(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + (0..100 | ForEach-Object { 0 }) + [System.IO.File]::WriteAllBytes($png, $bytes) + file $png +} + +T 'file (JPEG magic bytes)' { + $jpg = Join-Path $ws "fake.jpg" + [byte[]]$bytes = @(0xFF, 0xD8, 0xFF, 0xE0) + (0..100 | ForEach-Object { 0 }) + [System.IO.File]::WriteAllBytes($jpg, $bytes) + file $jpg +} + +T 'JSONC comment stripping' { + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $input1 = @' +{ + // This is a comment + "key": "value", // inline comment + "url": "https://example.com/path", // URL with double slashes + "nested": { + "a": 1 // deep comment + } +} +'@ + $stripped = $input1 -replace $jsoncPattern, '' + $parsed = $stripped | ConvertFrom-Json + if ($parsed.key -ne 'value') { throw "key wrong: $($parsed.key)" } + if ($parsed.url -ne 'https://example.com/path') { throw "URL corrupted: $($parsed.url)" } + if ($parsed.nested.a -ne 1) { throw "nested wrong" } +} + +T 'JSONC: strings with // not stripped' { + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $input2 = '{"url": "https://example.com"}' + $stripped = $input2 -replace $jsoncPattern, '' + $parsed = $stripped | ConvertFrom-Json + if ($parsed.url -ne 'https://example.com') { throw "URL in string corrupted: $($parsed.url)" } +} + +T 'Clear-ProfileCache preserves user-settings.json' { + $userSettings = Join-Path $realCacheDir 'user-settings.json' + if (-not (Test-Path $userSettings)) { throw "user-settings.json missing" } + $excluded = Get-ChildItem $realCacheDir -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq 'user-settings.json' } + if (-not $excluded) { throw "user-settings.json not found" } +} + +T 'Update-Tools: ProfileTools drive data' { + $installed = $script:ProfileTools | Where-Object { Get-Command $_.Cmd -ErrorAction SilentlyContinue } + if ($installed.Count -eq 0) { throw "No tools installed" } + foreach ($tool in $installed) { + $ver = try { (& $tool.Cmd $tool.VerCmd 2>$null | Where-Object { $_ -match '\d+\.\d+' } | Select-Object -First 1) } catch { $null } + if (-not $ver) { throw "$($tool.Name): VerCmd returned no version" } + } +} + +T 'setup.ps1 and profile Merge-JsonObject identical' { + $profileMerge = $ast.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -eq 'Merge-JsonObject' + }, $true) | Select-Object -First 1 + $setupMerge = $setupAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -eq 'Merge-JsonObject' + }, $true) | Select-Object -First 1 + if (-not $profileMerge) { throw "Not found in profile" } + if (-not $setupMerge) { throw "Not found in setup.ps1" } + $pBody = ($profileMerge.Extent.Text -replace '\s+', ' ').Trim() + $sBody = ($setupMerge.Extent.Text -replace '\s+', ' ').Trim() + if ($pBody -ne $sBody) { throw "Merge-JsonObject differs between profile and setup.ps1" } +} + +T 'setup.ps1 and profile Invoke-DownloadWithRetry match' { + $profileDl = $ast.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -eq 'Invoke-DownloadWithRetry' + }, $true) | Select-Object -First 1 + $setupDl = $setupAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -eq 'Invoke-DownloadWithRetry' + }, $true) | Select-Object -First 1 + if (-not $profileDl) { throw "Not found in profile" } + if (-not $setupDl) { throw "Not found in setup.ps1" } + $pText = $profileDl.Extent.Text + $sText = $setupDl.Extent.Text + if ($pText -notmatch 'MaxAttempts') { throw "Profile missing MaxAttempts" } + if ($sText -notmatch 'MaxAttempts') { throw "Setup missing MaxAttempts" } + if ($pText -notmatch 'BackoffSec') { throw "Profile missing BackoffSec" } + if ($sText -notmatch 'BackoffSec') { throw "Setup missing BackoffSec" } +} + +# ##################################################################### +# CLEANUP & SUMMARY +# ##################################################################### +Set-Location $origDir +Remove-Item $ws -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host "`n========================================================" -ForegroundColor Cyan +$color = if ($fail -gt 0) { 'Red' } else { 'Green' } +Write-Host " Results: $ok ok, $fail bugs found" -ForegroundColor $color + +if ($bugs.Count -gt 0) { + Write-Host "`n BUGS FOUND:" -ForegroundColor Red + foreach ($b in $bugs) { + Write-Host " - $($b.Command): $($b.Error)" -ForegroundColor Red + } +} + +Write-Host "========================================================`n" -ForegroundColor Cyan diff --git a/tests/test.ps1 b/tests/test.ps1 new file mode 100644 index 0000000..84e8524 --- /dev/null +++ b/tests/test.ps1 @@ -0,0 +1,2157 @@ +# test.ps1 - Full local test suite (mirrors CI lint + install-flow, plus local-only checks) +# Usage: pwsh -NoProfile -File tests/test.ps1 +# pwsh -NoProfile -File tests/test.ps1 -SkipPS5 +# Covers every check from .github/workflows/ci.yml plus profile-level validation. +param( + [switch]$SkipPS5 # Skip PS5 parse check if powershell.exe is unavailable +) + +$ErrorActionPreference = 'Stop' +# This script lives in tests/. repoRoot points at the parent directory (where the profile lives). +$repoRoot = Split-Path -Parent $PSScriptRoot +$passed = 0 +$failed = 0 +$skipped = 0 +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + +# Always-on sandbox cleanup. Child pwsh processes usually clean their own $sb in a finally +# block, but a Ctrl+C at this orchestrator kills the child before that runs, leaving psp-* +# dirs behind. The trap fires on terminating errors (including StopUpstreamCommandsException +# from Ctrl+C) and Register-EngineEvent fires on normal script exit. +$script:TestArtifactPatterns = @( + 'psp-install-*', 'psp-sandbox-*', 'psp-sandbox-all-*', 'psp-lifecycle-*', + 'psp-setprofile-*', 'psp-exec-*', 'psp-dltest-*', 'psp-omp-*', 'fresh-install-*' +) +$script:SweepTestArtifacts = { + foreach ($pat in $script:TestArtifactPatterns) { + Get-ChildItem -LiteralPath $env:TEMP -Filter $pat -Force -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } + } +} +Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action $script:SweepTestArtifacts | Out-Null +trap { + Write-Host '' + Write-Host ('Aborted: {0}' -f $_.Exception.Message) -ForegroundColor Yellow + Write-Host 'Sweeping sandbox artifacts in $env:TEMP ...' -ForegroundColor Yellow + & $script:SweepTestArtifacts + break +} + +# Files that are excluded from static scans (test.ps1 itself contains patterns it checks for) +$selfExclude = 'test.ps1' + +function Write-Result { + param([string]$Name, [string]$Status, [string]$Detail) + switch ($Status) { + 'PASS' { Write-Host " PASS $Name" -ForegroundColor Green; $script:passed++ } + 'FAIL' { + Write-Host " FAIL $Name" -ForegroundColor Red + if ($Detail) { Write-Host " $Detail" -ForegroundColor Yellow } + $script:failed++ + } + 'SKIP' { + Write-Host " SKIP $Name" -ForegroundColor DarkGray + if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGray } + $script:skipped++ + } + } +} + +Write-Host '' +Write-Host '========== PowerShellPerfect Full Test Suite ==========' -ForegroundColor Cyan +Write-Host '' + +# Collect source files once for reuse (exclude .git and self) +$srcPs1 = Get-ChildItem -Path $repoRoot -Recurse -Include *.ps1 | + Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne $selfExclude } +$srcAll = Get-ChildItem -Path $repoRoot -Recurse -Include *.ps1,*.json,*.md,*.yml | + Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne $selfExclude } + +$profilePath = Join-Path $repoRoot 'Microsoft.PowerShell_profile.ps1' +$setupPath = Join-Path $repoRoot 'setup.ps1' + +# ===================================================================== +# INSTALL: fresh install sandbox +# ===================================================================== +Write-Host '--- Install: fresh setup sandbox ---' -ForegroundColor Magenta +Write-Host '' + +# ------------------------------------------------------- +# 1. Fresh install sandbox (simulates setup.ps1 on clean system) +# ------------------------------------------------------- +Write-Host '[1/26] Fresh install sandbox' -ForegroundColor Cyan +try { + $sandboxScript = Join-Path $env:TEMP "fresh-install-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($RepoRoot) +$ErrorActionPreference = 'Stop' + +# Parse setup.ps1 AST to extract functions without running install flow +$setupFile = Join-Path $RepoRoot 'setup.ps1' +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($setupFile, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "setup.ps1 has $($parseErrors.Count) parse error(s)" } + +$fnDefs = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) +foreach ($fn in $fnDefs) { Invoke-Expression $fn.Extent.Text } +Write-Host " Extracted $($fnDefs.Count) functions from setup.ps1" + +# Extract $EditorCandidates +$varDefs = $ast.FindAll({ + $args[0] -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $args[0].Left.Extent.Text -eq '$EditorCandidates' +}, $true) +if ($varDefs.Count -gt 0) { Invoke-Expression $varDefs[0].Extent.Text } + +# ===== Create sandbox ===== +$sb = Join-Path $env:TEMP "psp-install-$([System.IO.Path]::GetRandomFileName())" +$cacheDir = Join-Path $sb 'Local\PowerShellProfile' +$ps7Dir = Join-Path $sb 'Documents\PowerShell' +$ps5Dir = Join-Path $sb 'Documents\WindowsPowerShell' +$wtLocal = Join-Path $sb 'Local\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' +New-Item -ItemType Directory -Path $cacheDir, $ps7Dir, $ps5Dir, $wtLocal -Force | Out-Null +$configCachePath = $cacheDir + +# Minimal WT settings.json +[System.IO.File]::WriteAllText( + (Join-Path $wtLocal 'settings.json'), + '{"profiles":{"defaults":{"font":{"face":"Consolas"}},"list":[]},"schemes":[],"actions":[]}', + [System.Text.UTF8Encoding]::new($false) +) + +$origLocal = $env:LOCALAPPDATA +$origProfile = $PROFILE +$env:LOCALAPPDATA = Join-Path $sb 'Local' +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$errors = @() +try { + # --- Phase 1: Profile copy (simulates setprofile.ps1) --- + $profileSrc = Join-Path $RepoRoot 'Microsoft.PowerShell_profile.ps1' + foreach ($d in @($ps7Dir, $ps5Dir)) { + Copy-Item $profileSrc (Join-Path $d 'Microsoft.PowerShell_profile.ps1') + } + foreach ($d in @($ps7Dir, $ps5Dir)) { + $pf = Join-Path $d 'Microsoft.PowerShell_profile.ps1' + if (-not (Test-Path $pf)) { $errors += "Profile not copied to $d" } + } + Write-Host ' OK Phase 1: profile copy' + + # --- Phase 2: Config download (theme.json, terminal-config.json) --- + $themeUrl = 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/theme.json' + $tcUrl = 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/terminal-config.json' + Invoke-DownloadWithRetry -Uri $themeUrl -OutFile (Join-Path $cacheDir 'theme.json') + Invoke-DownloadWithRetry -Uri $tcUrl -OutFile (Join-Path $cacheDir 'terminal-config.json') + foreach ($cf in @('theme.json', 'terminal-config.json')) { + $cfPath = Join-Path $cacheDir $cf + if (-not (Test-Path $cfPath) -or (Get-Item $cfPath).Length -eq 0) { $errors += "$cf download failed" } + else { $null = Get-Content $cfPath -Raw | ConvertFrom-Json } + } + Write-Host ' OK Phase 2: config download' + + # --- Phase 3: OMP theme download --- + $themeJson = Get-Content (Join-Path $cacheDir 'theme.json') -Raw | ConvertFrom-Json + $themeName = $themeJson.theme.name + $themeFileUrl = $themeJson.theme.url + Invoke-DownloadWithRetry -Uri $themeFileUrl -OutFile (Join-Path $cacheDir "$themeName.omp.json") + $ompFile = Join-Path $cacheDir "$themeName.omp.json" + if (-not (Test-Path $ompFile) -or (Get-Item $ompFile).Length -eq 0) { $errors += 'OMP theme download failed' } + else { Write-Host " OK Phase 3: OMP theme ($themeName)" } + + # --- Phase 4: user-settings.json template --- + $usPath = Join-Path $cacheDir 'user-settings.json' + $usTemplate = '{"_comment": "User overrides. Keys mirror theme.json / terminal-config.json.", "windowsTerminal": {}}' + [System.IO.File]::WriteAllText($usPath, $usTemplate, [System.Text.UTF8Encoding]::new($false)) + if (-not (Test-Path $usPath)) { $errors += 'user-settings.json not created' } + else { Write-Host ' OK Phase 4: user-settings.json' } + + # --- Phase 5: profile_user.ps1 template --- + foreach ($d in @($ps7Dir, $ps5Dir)) { + $upPath = Join-Path $d 'profile_user.ps1' + [System.IO.File]::WriteAllText($upPath, '# Personal overrides', [System.Text.UTF8Encoding]::new($false)) + if (-not (Test-Path $upPath)) { $errors += "profile_user.ps1 not created in $d" } + } + Write-Host ' OK Phase 5: profile_user.ps1' + + # --- Phase 6: WT settings merge --- + $tcJson = Get-Content (Join-Path $cacheDir 'terminal-config.json') -Raw | ConvertFrom-Json + $wtPath = Join-Path $wtLocal 'settings.json' + $wt = Get-Content $wtPath -Raw | ConvertFrom-Json + $wtDefaults = $wt.profiles.defaults + $tcJson.defaults.PSObject.Properties | ForEach-Object { + $wtDefaults | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value -Force + } + if ($themeJson.windowsTerminal.colorScheme) { + $wtDefaults | Add-Member -NotePropertyName 'colorScheme' -NotePropertyValue $themeJson.windowsTerminal.colorScheme -Force + } + [System.IO.File]::WriteAllText($wtPath, ($wt | ConvertTo-Json -Depth 100), [System.Text.UTF8Encoding]::new($false)) + $wtCheck = Get-Content $wtPath -Raw | ConvertFrom-Json + if (-not $wtCheck.profiles.defaults.font.face) { $errors += 'WT merge: missing font.face' } + if ($tcJson.defaults.opacity -and $wtCheck.profiles.defaults.opacity -ne $tcJson.defaults.opacity) { $errors += 'WT merge: opacity mismatch' } + Write-Host ' OK Phase 6: WT settings merge' + + # --- Phase 7: Verify profile loads in sandbox --- + $env:CI = 'true' + . (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1') + if (-not (Get-Command 'Show-Help' -ErrorAction SilentlyContinue)) { $errors += 'Profile did not load (Show-Help missing)' } + if (-not (Get-Command 'Update-Profile' -ErrorAction SilentlyContinue)) { $errors += 'Profile did not load (Update-Profile missing)' } + Write-Host ' OK Phase 7: profile loads in sandbox' + + # --- Phase 8: Nerd Font detection --- + try { + [void][System.Reflection.Assembly]::LoadWithPartialName('System.Drawing') + $fc = New-Object System.Drawing.Text.InstalledFontCollection + $nfMatch = @($fc.Families | Where-Object { $_.Name -match 'Caskaydia|NF|Nerd' }) + $fc.Dispose() + if ($nfMatch.Count -gt 0) { Write-Host " OK Phase 8: Nerd Font found ($($nfMatch[0].Name))" } + else { Write-Host ' WARN Phase 8: No Nerd Font detected (optional)' } + } + catch { Write-Host " WARN Phase 8: Font check skipped ($_)" } +} +finally { + $env:LOCALAPPDATA = $origLocal + $global:PROFILE = $origProfile + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($errors) { + $errors | ForEach-Object { Write-Host "ASSERT: $_" -ForegroundColor Red } + exit 1 +} +Write-Host 'Fresh install sandbox passed' +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $instOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -RepoRoot $repoRoot 2>&1 + foreach ($line in $instOutput) { + $color = if ($line -match '^\s+OK') { 'Green' } elseif ($line -match 'ASSERT:|FAIL') { 'Red' } elseif ($line -match 'WARN') { 'Yellow' } else { 'White' } + Write-Host " $line" -ForegroundColor $color + } + $assertLines = @($instOutput | Where-Object { $_ -match 'ASSERT:' }) + if ($assertLines) { $assertLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } } + if ($LASTEXITCODE -ne 0) { throw 'Fresh install sandbox failed' } + Write-Result 'Fresh install sandbox' 'PASS' +} +catch { Write-Result 'Fresh install sandbox' 'FAIL' $_.Exception.Message } +finally { Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue } + +# ===================================================================== +# CI JOB 1: lint +# ===================================================================== +Write-Host '' +Write-Host '--- CI: lint ---' -ForegroundColor Magenta +Write-Host '' + +# ------------------------------------------------------- +# 2. PSScriptAnalyzer +# ------------------------------------------------------- +Write-Host '[2/26] PSScriptAnalyzer' -ForegroundColor Cyan +try { + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.24.0 -Force -Scope CurrentUser + } + $results = Invoke-ScriptAnalyzer -Path $repoRoot -Recurse -ExcludeRule @( + 'PSAvoidUsingWriteHost' + 'PSAvoidUsingWMICmdlet' + 'PSUseShouldProcessForStateChangingFunctions' + 'PSUseBOMForUnicodeEncodedFile' + 'PSReviewUnusedParameter' + 'PSUseSingularNouns' + ) + $issues = $results | Where-Object Severity -in 'Error','Warning' + if ($issues) { + $issues | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + Write-Result 'PSScriptAnalyzer' 'FAIL' "$($issues.Count) warning(s)/error(s)" + } + else { Write-Result 'PSScriptAnalyzer' 'PASS' } +} +catch { Write-Result 'PSScriptAnalyzer' 'FAIL' $_.Exception.Message } + +# ------------------------------------------------------- +# 3. Smoke test (pwsh, non-interactive) +# ------------------------------------------------------- +Write-Host '[3/26] Smoke test (pwsh)' -ForegroundColor Cyan +if ($env:OS -ne 'Windows_NT') { + # The profile is Windows-targeted (LOCALAPPDATA, WindowsPrincipal, etc.); it cannot load cleanly + # off-Windows, so loading it here only produces platform errors. CI runs this on windows-latest. + Write-Result 'Smoke test (pwsh)' 'SKIP' 'non-Windows host (profile is Windows-targeted)' +} +else { + try { + $env:CI = 'true' + # Exit code alone misses non-terminating errors; also scan the error stream (warnings suppressed + # since user-file/plugin load failures are non-fatal). Mirrors the CI smoke step. + $smoke = pwsh -NonInteractive -NoProfile -Command "`$WarningPreference='SilentlyContinue'; . '$profilePath'" 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } + $smokeErrors = @($smoke | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) + if ($smokeErrors.Count -gt 0) { throw "$($smokeErrors.Count) error(s) during load: $($smokeErrors[0])" } + Write-Result 'Smoke test (pwsh)' 'PASS' + } + catch { Write-Result 'Smoke test (pwsh)' 'FAIL' $_.Exception.Message } + finally { $env:CI = $null } +} + +# ------------------------------------------------------- +# 4. Smoke test (PS5, non-interactive) +# ------------------------------------------------------- +Write-Host '[4/26] Smoke test (PS5)' -ForegroundColor Cyan +if ($SkipPS5 -or -not (Get-Command powershell.exe -ErrorAction SilentlyContinue)) { + Write-Result 'Smoke test (PS5)' 'SKIP' 'powershell.exe not available or -SkipPS5' +} +else { + try { + $escaped = $profilePath -replace "'", "''" + $ps5Smoke = powershell.exe -NoProfile -Command "`$env:CI = 'true'; `$WarningPreference = 'SilentlyContinue'; . '$escaped'" 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } + $ps5Errors = @($ps5Smoke | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) + if ($ps5Errors.Count -gt 0) { throw "$($ps5Errors.Count) error(s) during load: $($ps5Errors[0])" } + Write-Result 'Smoke test (PS5)' 'PASS' + } + catch { Write-Result 'Smoke test (PS5)' 'FAIL' $_.Exception.Message } +} + +# ------------------------------------------------------- +# 5. PS5 parse check (all .ps1 files) +# ------------------------------------------------------- +Write-Host '[5/26] PS5 parse check' -ForegroundColor Cyan +if ($SkipPS5 -or -not (Get-Command powershell.exe -ErrorAction SilentlyContinue)) { + Write-Result 'PS5 parse check' 'SKIP' 'powershell.exe not available or -SkipPS5' +} +else { + $ps5Errors = 0 + # Include ALL .ps1 in the repo (same as CI) + $allPs1 = Get-ChildItem -Path $repoRoot -Filter *.ps1 -Recurse | Where-Object { $_.FullName -notlike '*\.git\*' } + foreach ($file in $allPs1) { + $escaped = $file.FullName -replace "'", "''" + powershell.exe -NoProfile -Command "`$t = `$null; `$e = `$null; [void][System.Management.Automation.Language.Parser]::ParseFile('$escaped', [ref]`$t, [ref]`$e); if (`$e.Count -gt 0) { `$e | ForEach-Object { Write-Host `$_ }; exit 1 }" + if ($LASTEXITCODE -ne 0) { + Write-Host " FAIL: $($file.Name)" -ForegroundColor Red + $ps5Errors++ + } + } + if ($ps5Errors -gt 0) { Write-Result 'PS5 parse check' 'FAIL' "$ps5Errors file(s) failed" } + else { Write-Result 'PS5 parse check' 'PASS' } +} + +# ------------------------------------------------------- +# 6. Hardcoded user paths +# ------------------------------------------------------- +Write-Host '[6/26] Hardcoded paths' -ForegroundColor Cyan +$pathPatterns = @('C:\\Users\\', 'C:/Users/', '/home/', '\\\\Users\\\\') +$pathFinds = @() +foreach ($file in $srcPs1) { + $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue + for ($i = 0; $i -lt $lines.Count; $i++) { + foreach ($p in $pathPatterns) { + if ($lines[$i] -match $p) { + $pathFinds += [PSCustomObject]@{ File = $file.Name; Line = $i + 1; Match = $lines[$i].Trim() } + } + } + } +} +if ($pathFinds) { + $pathFinds | Format-Table -AutoSize + Write-Result 'Hardcoded paths' 'FAIL' "$($pathFinds.Count) match(es)" +} +else { Write-Result 'Hardcoded paths' 'PASS' } + +# ------------------------------------------------------- +# 7. Non-ASCII characters +# ------------------------------------------------------- +Write-Host '[7/26] Non-ASCII characters' -ForegroundColor Cyan +$asciiFinds = @() +foreach ($file in $srcPs1) { + $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '[^\x00-\x7E]') { + $asciiFinds += [PSCustomObject]@{ File = $file.Name; Line = $i + 1; Match = $lines[$i].Trim() } + } + } +} +if ($asciiFinds) { + $asciiFinds | Format-Table -AutoSize + Write-Result 'Non-ASCII characters' 'FAIL' "$($asciiFinds.Count) line(s)" +} +else { Write-Result 'Non-ASCII characters' 'PASS' } + +# ------------------------------------------------------- +# 8. UTF-8 BOM +# ------------------------------------------------------- +Write-Host '[8/26] UTF-8 BOM' -ForegroundColor Cyan +$bomFinds = @() +$bomFiles = Get-ChildItem -Path $repoRoot -Recurse -Include *.ps1,*.json | + Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne $selfExclude } +foreach ($file in $bomFiles) { + $bytes = [System.IO.File]::ReadAllBytes($file.FullName) + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $bomFinds += $file.Name + } +} +if ($bomFinds) { + $bomFinds | ForEach-Object { Write-Host " BOM: $_" -ForegroundColor Red } + Write-Result 'UTF-8 BOM' 'FAIL' "$($bomFinds.Count) file(s)" +} +else { Write-Result 'UTF-8 BOM' 'PASS' } + +# ------------------------------------------------------- +# 9. Set-Content -Encoding UTF8 (produces BOM on PS5) +# ------------------------------------------------------- +Write-Host '[9/26] Set-Content Encoding check' -ForegroundColor Cyan +$scFinds = @() +foreach ($file in $srcPs1) { + $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match 'Set-Content\s.*-Encoding\s+UTF8' -and $lines[$i] -notmatch '^\s*#') { + $scFinds += [PSCustomObject]@{ File = $file.Name; Line = $i + 1; Match = $lines[$i].Trim() } + } + } +} +if ($scFinds) { + $scFinds | Format-Table -AutoSize + Write-Result 'Set-Content UTF8' 'FAIL' "$($scFinds.Count) match(es)" +} +else { Write-Result 'Set-Content UTF8' 'PASS' } + +# ------------------------------------------------------- +# 10. Secrets scan +# ------------------------------------------------------- +Write-Host '[10/26] Secrets scan' -ForegroundColor Cyan +$secretPatterns = @( + '(?i)(api[_-]?key|apikey)\s*[:=]\s*[''"][A-Za-z0-9+/=]{16,}[''"]' + '(?i)(secret|token|password)\s*[:=]\s*[''"][^''"]{8,}[''"]' + '(?i)(aws_access_key_id|aws_secret_access_key)\s*[:=]' + 'ghp_[A-Za-z0-9]{36}' + 'github_pat_[A-Za-z0-9_]{82}' + 'sk-[A-Za-z0-9]{32,}' + '(?i)connectionstring\s*[:=]\s*[''"]Server=' +) +$scanFiles = $srcAll | Where-Object { $_.FullName -notlike '*\.github\workflows\*' } +$secretFinds = @() +foreach ($file in $scanFiles) { + $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue + if (-not $content) { continue } + foreach ($sp in $secretPatterns) { + if ($content -match $sp) { + $secretFinds += [PSCustomObject]@{ File = $file.Name; Pattern = $sp.Substring(0, [Math]::Min(40, $sp.Length)) + '...' } + } + } +} +if ($secretFinds) { + $secretFinds | Format-Table -AutoSize + Write-Result 'Secrets scan' 'FAIL' "$($secretFinds.Count) match(es)" +} +else { Write-Result 'Secrets scan' 'PASS' } + +# ===================================================================== +# CI JOB 2: install-flow +# ===================================================================== +Write-Host '' +Write-Host '--- CI: install-flow ---' -ForegroundColor Magenta +Write-Host '' + +# ------------------------------------------------------- +# 11. JSON config validation +# ------------------------------------------------------- +Write-Host '[11/26] JSON config validation' -ForegroundColor Cyan +$jsonErrors = 0 +foreach ($jf in @('theme.json', 'terminal-config.json')) { + $jfPath = Join-Path $repoRoot $jf + try { + $null = Get-Content $jfPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + } + catch { Write-Host " FAIL: $jf - $_" -ForegroundColor Red; $jsonErrors++ } +} +if ($jsonErrors -gt 0) { Write-Result 'JSON configs' 'FAIL' "$jsonErrors file(s)" } +else { Write-Result 'JSON configs' 'PASS' } + +# ------------------------------------------------------- +# 12. Config schema (required keys) +# ------------------------------------------------------- +Write-Host '[12/26] Config schema' -ForegroundColor Cyan +$theme = Get-Content (Join-Path $repoRoot 'theme.json') -Raw | ConvertFrom-Json +$terminal = Get-Content (Join-Path $repoRoot 'terminal-config.json') -Raw | ConvertFrom-Json +$schemaErrors = @() +if (-not $theme.theme.name) { $schemaErrors += 'theme.json: missing theme.name' } +if (-not $theme.theme.url) { $schemaErrors += 'theme.json: missing theme.url' } +if (-not $terminal.defaults) { $schemaErrors += 'terminal-config.json: missing defaults' } +if (-not $terminal.fontInstall) { $schemaErrors += 'terminal-config.json: missing fontInstall' } +if (-not $terminal.fontInstall.name) { $schemaErrors += 'terminal-config.json: missing fontInstall.name' } +if (-not $terminal.fontInstall.displayName) { $schemaErrors += 'terminal-config.json: missing fontInstall.displayName' } +if (-not $terminal.fontInstall.version) { $schemaErrors += 'terminal-config.json: missing fontInstall.version' } +if ($schemaErrors) { + $schemaErrors | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + Write-Result 'Config schema' 'FAIL' "$($schemaErrors.Count) issue(s)" +} +else { Write-Result 'Config schema' 'PASS' } + +# ------------------------------------------------------- +# 13. setup.ps1 dry-run (required function definitions) +# ------------------------------------------------------- +Write-Host '[13/26] setup.ps1 function definitions' -ForegroundColor Cyan +try { + $setupContent = Get-Content $setupPath -Raw + $requiredFunctions = @( + 'Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', + 'Install-WingetPackage', 'Merge-JsonObject', 'Select-PreferredEditor', + 'Invoke-DownloadWithRetry', 'Remove-SafeTempDirectory', + 'Update-SessionPathFromRegistry' + ) + $missingFns = @() + foreach ($fn in $requiredFunctions) { + if ($setupContent -notmatch "function\s+$fn\b") { $missingFns += $fn } + } + $remotePreflightStart = Select-String -Path $setupPath -Pattern 'For remote installs, verify/download' | Select-Object -First 1 + $remotePreflight = Select-String -Path $setupPath -Pattern '^\s*Initialize-RemoteInstallBundle\s*$' | + Where-Object { $remotePreflightStart -and $_.LineNumber -gt $remotePreflightStart.LineNumber } | + Select-Object -First 1 + $policyMutation = Select-String -Path $setupPath -Pattern 'Set-ExecutionPolicy RemoteSigned' | Select-Object -First 1 + if (-not $remotePreflight) { $missingFns += 'remote bundle preflight call' } + elseif ($policyMutation -and $remotePreflight.LineNumber -gt $policyMutation.LineNumber) { + $missingFns += 'remote bundle preflight before mutations' + } + if ($setupContent -match [regex]::Escape('$env:PATH = [System.Environment]::GetEnvironmentVariable(''PATH'', ''Machine'') + '';'' + [System.Environment]::GetEnvironmentVariable(''PATH'', ''User'')')) { + $missingFns += 'safe PATH registry refresh' + } + if ($missingFns) { + $missingFns | ForEach-Object { Write-Host " Missing: $_" -ForegroundColor Red } + Write-Result 'setup.ps1 functions' 'FAIL' "$($missingFns.Count) missing" + } + else { Write-Result 'setup.ps1 functions' 'PASS' } +} +catch { Write-Result 'setup.ps1 functions' 'FAIL' $_.Exception.Message } + +# ------------------------------------------------------- +# 14. Merge-JsonObject unit tests +# ------------------------------------------------------- +Write-Host '[14/26] Merge-JsonObject tests' -ForegroundColor Cyan +try { + function Merge-JsonObject($base, $override) { + foreach ($prop in $override.PSObject.Properties) { + $baseVal = $base.PSObject.Properties[$prop.Name] + if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) { + Merge-JsonObject $baseVal.Value $prop.Value + } + else { + $base | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force + } + } + } + + # Flat merge + $b = [PSCustomObject]@{ a = 1; b = 2 } + Merge-JsonObject $b ([PSCustomObject]@{ b = 99; c = 3 }) + if ($b.a -ne 1 -or $b.b -ne 99 -or $b.c -ne 3) { throw 'Flat merge failed' } + + # Deep merge preserves nested keys + $b = [PSCustomObject]@{ font = [PSCustomObject]@{ face = 'Consolas'; size = 11 }; opacity = 75 } + Merge-JsonObject $b ([PSCustomObject]@{ font = [PSCustomObject]@{ size = 14 } }) + if ($b.font.face -ne 'Consolas' -or $b.font.size -ne 14 -or $b.opacity -ne 75) { throw 'Deep merge failed' } + + # Override replaces scalar with object + $b = [PSCustomObject]@{ theme = 'simple' } + Merge-JsonObject $b ([PSCustomObject]@{ theme = [PSCustomObject]@{ name = 'pure' } }) + if ($b.theme.name -ne 'pure') { throw 'Object replacement failed' } + + Write-Result 'Merge-JsonObject tests' 'PASS' +} +catch { Write-Result 'Merge-JsonObject tests' 'FAIL' $_.Exception.Message } + +# ------------------------------------------------------- +# 15. WT settings merge mock (defaults + scheme + keybindings + JSON roundtrip) +# ------------------------------------------------------- +Write-Host '[15/26] WT settings merge mock' -ForegroundColor Cyan +try { + $mockWt = [PSCustomObject]@{ + profiles = [PSCustomObject]@{ + defaults = [PSCustomObject]@{ font = [PSCustomObject]@{ face = 'Consolas'; size = 10 } } + list = @() + } + schemes = @(); actions = @() + } + $defaults = $mockWt.profiles.defaults + + # Apply terminal-config defaults + $terminal.defaults.PSObject.Properties | ForEach-Object { + $defaults | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value -Force + } + if ($theme.windowsTerminal.colorScheme) { + $defaults | Add-Member -NotePropertyName 'colorScheme' -NotePropertyValue $theme.windowsTerminal.colorScheme -Force + } + if ($defaults.opacity -ne $terminal.defaults.opacity) { throw "opacity mismatch" } + if ($defaults.colorScheme -ne $theme.windowsTerminal.colorScheme) { throw "colorScheme mismatch" } + if ($defaults.font.face -ne $terminal.defaults.font.face) { throw "font.face mismatch" } + + # Scheme upsert + $schemeDef = $theme.windowsTerminal.scheme + $mockWt.schemes = @(@($mockWt.schemes | Where-Object { $_ -and $_.name -ne $schemeDef.name }) + ([PSCustomObject]$schemeDef)) + if ($mockWt.schemes.Count -ne 1 -or $mockWt.schemes[0].name -ne $schemeDef.name) { throw 'Scheme upsert failed' } + + # Keybinding upsert + foreach ($kb in $terminal.keybindings) { + $mockWt.actions = @($mockWt.actions) + ([PSCustomObject]@{ keys = $kb.keys; command = $kb.command }) + } + $firstKb = @($terminal.keybindings)[0] + $found = $mockWt.actions | Where-Object { $_.keys -eq $firstKb.keys } + if (-not $found -or $found.command -ne $firstKb.command) { throw 'Keybinding upsert failed' } + + # JSON roundtrip (depth matches production profile/setup writes) + $null = ($mockWt | ConvertTo-Json -Depth 100) | ConvertFrom-Json -ErrorAction Stop + + Write-Result 'WT merge mock' 'PASS' +} +catch { Write-Result 'WT merge mock' 'FAIL' $_.Exception.Message } + +# ===================================================================== +# LOCAL-ONLY: profile-level validation +# ===================================================================== +Write-Host '' +Write-Host '--- Local: profile validation ---' -ForegroundColor Magenta +Write-Host '' + +# ------------------------------------------------------- +# 16. ProfileTools metadata (every entry has required fields) +# ------------------------------------------------------- +Write-Host '[16/26] ProfileTools metadata' -ForegroundColor Cyan +try { + $env:CI = 'true' + $output = pwsh -NonInteractive -NoProfile -Command @" +. '$profilePath' +`$requiredKeys = @('Name','Id','Cmd','Cache','VerCmd') +`$errors = @() +foreach (`$tool in `$script:ProfileTools) { + foreach (`$k in `$requiredKeys) { + if (-not `$tool.ContainsKey(`$k)) { `$errors += "`$(`$tool.Name): missing `$k" } + } + if (`$tool.Id -and `$tool.Id -notmatch '^[A-Za-z0-9._-]+$') { `$errors += "`$(`$tool.Name): invalid Id format" } +} +if (`$errors) { `$errors | ForEach-Object { Write-Host `$_ }; exit 1 } +Write-Host "OK: `$(`$script:ProfileTools.Count) tools validated" +"@ + if ($LASTEXITCODE -ne 0) { throw "Validation errors (see above)" } + Write-Result 'ProfileTools metadata' 'PASS' +} +catch { Write-Result 'ProfileTools metadata' 'FAIL' $_.Exception.Message } +finally { $env:CI = $null } + +# ------------------------------------------------------- +# 17. Key functions exist after profile load +# ------------------------------------------------------- +Write-Host '[17/26] Key functions exist' -ForegroundColor Cyan +try { + $env:CI = 'true' + $output = pwsh -NonInteractive -NoProfile -Command @" +. '$profilePath' +`$expected = @( + 'Update-Profile','Update-PowerShell','Update-Tools','Uninstall-Profile', + 'Clear-ProfileCache','Clear-Cache','Show-Help','Resolve-PreferredEditor', + 'edit','Edit-Profile','Invoke-DownloadWithRetry', + 'Get-SystemBootTime','prompt', + 'touch','ff','grep','head','tail','sed','which','file','export', + 'pkill','pgrep','mkcd','trash','extract','sizeof', + 'pubip','localip','uptime','sysinfo','df','flushdns','ports', + 'checkport','portscan','tlscert','ipinfo','whois','nslook', + 'hash','checksum','genpass','b64','b64d','jwtd','uuid','epoch','vt', + 'urlencode','urldecode','vtscan', + 'killport','http','prettyjson','hb','timer','watch','bak', + 'hosts','weather','speedtest','wifipass','eventlog','path','env', + 'svc','rdp','cpy','pst','Invoke-Clipboard', + 'gs','ga','gc','gpush','gpull','g','gcl','gcom','lazyg', + 'ls','la','ll','lt','cat','docs','dtop','admin','reload', + 'ep','su' +) +if (Get-Command ssh -ErrorAction SilentlyContinue) { + `$expected += @('Copy-SshKey','keygen','ssh-copy-key') +} +if (Get-Command docker -ErrorAction SilentlyContinue) { + `$expected += @('dps','dpa','dimg','dlogs','dex','dstop','dprune') +} +`$missing = @() +foreach (`$fn in `$expected) { + if (-not (Get-Command `$fn -ErrorAction SilentlyContinue)) { `$missing += `$fn } +} +if (`$missing) { + `$missing | ForEach-Object { Write-Host "Missing: `$_" } + exit 1 +} +Write-Host "OK: `$(`$expected.Count) functions verified" +"@ + if ($LASTEXITCODE -ne 0) { throw "Missing functions (see above)" } + Write-Result 'Key functions exist' 'PASS' +} +catch { Write-Result 'Key functions exist' 'FAIL' $_.Exception.Message } +finally { $env:CI = $null } + +# ------------------------------------------------------- +# 18. Uninstall-Profile -WhatIf (all phases produce output) +# ------------------------------------------------------- +Write-Host '[18/26] Uninstall-Profile -WhatIf' -ForegroundColor Cyan +try { + $env:CI = 'true' + $output = pwsh -NonInteractive -NoProfile -Command ". '$profilePath'; Uninstall-Profile -All -WhatIf *>&1" 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } + $whatifLines = @($output | Where-Object { $_ -match 'What if' }) + if ($whatifLines.Count -eq 0) { throw 'No WhatIf output produced' } + # Core phases that always produce output when sandboxed with real profile + cache files. + # 'Restore WT' is NOT required because it only fires when Windows Terminal is installed; + # after Get-WindowsTerminalSettingsPath became variant-aware, WT-less hosts skip that phase. + $requiredPhases = @('Remove cache', 'Remove profile file') + $missingPhases = @() + foreach ($ph in $requiredPhases) { + if (-not ($whatifLines | Where-Object { $_ -match $ph })) { $missingPhases += $ph } + } + if ($missingPhases) { + $missingPhases | ForEach-Object { Write-Host " Missing phase: $_" -ForegroundColor Yellow } + throw "$($missingPhases.Count) phase(s) missing from WhatIf output" + } + Write-Result 'Uninstall-Profile -WhatIf' 'PASS' "$($whatifLines.Count) action(s), all phases OK" +} +catch { Write-Result 'Uninstall-Profile -WhatIf' 'FAIL' $_.Exception.Message } +finally { $env:CI = $null } + +# ------------------------------------------------------- +# 19. Uninstall-Profile sandbox: core (real file deletion) +# ------------------------------------------------------- +Write-Host '[19/26] Uninstall sandbox: core' -ForegroundColor Cyan +try { + $env:CI = 'true' + # Build sandbox test as a temp script (avoids escaping hell) + $sandboxScript = Join-Path $env:TEMP "uninstall-sandbox-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($ProfileSource) +$ErrorActionPreference = 'Stop' + +# 2. Load profile with real env to get function definitions +$env:CI = 'true' +. $ProfileSource + +# 3. Create sandbox directory tree +$sb = Join-Path $env:TEMP "psp-sandbox-$([System.IO.Path]::GetRandomFileName())" + +$wtLocal = Join-Path $sb 'Local\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' +$cacheDir = Join-Path $sb 'Local\PowerShellProfile' +$ps7Dir = Join-Path $sb 'Documents\PowerShell' +$ps5Dir = Join-Path $sb 'Documents\WindowsPowerShell' + +New-Item -ItemType Directory -Path $wtLocal, $cacheDir, $ps7Dir, $ps5Dir -Force | Out-Null + +# WT: original settings + 2 backups with different content +[System.IO.File]::WriteAllText((Join-Path $wtLocal 'settings.json'), '{"modified": true}', [System.Text.UTF8Encoding]::new($false)) +$bakOld = Join-Path $wtLocal 'settings.json.20240101-100000.bak' +$bakNew = Join-Path $wtLocal 'settings.json.20240601-120000.bak' +[System.IO.File]::WriteAllText($bakOld, '{"original": "old"}', [System.Text.UTF8Encoding]::new($false)) +Start-Sleep -Milliseconds 50 +[System.IO.File]::WriteAllText($bakNew, '{"original": "newest"}', [System.Text.UTF8Encoding]::new($false)) + +# Cache files +foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json')) { + [System.IO.File]::WriteAllText((Join-Path $cacheDir $f), "# $f placeholder", [System.Text.UTF8Encoding]::new($false)) +} + +# User plugins dir: must survive a plain uninstall (user-authored, auto-loaded scripts); only -RemoveUserData removes it. +$pluginsDir = Join-Path $cacheDir 'plugins' +New-Item -ItemType Directory -Path $pluginsDir -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $pluginsDir 'myplugin.ps1'), '# user plugin', [System.Text.UTF8Encoding]::new($false)) + +# Profile files in both dirs +foreach ($d in @($ps7Dir, $ps5Dir)) { + [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), '# profile', [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user overrides', [System.Text.UTF8Encoding]::new($false)) +} + +# 4. Override environment to point at sandbox +$origLocal = $env:LOCALAPPDATA +$origProfile = $PROFILE +$env:LOCALAPPDATA = Join-Path $sb 'Local' +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$errors = @() +try { + # ===== TEST A: Core uninstall (no switches) ===== + Uninstall-Profile -Confirm:$false + + # WT settings.json should have backup content + $wtContent = [System.IO.File]::ReadAllText((Join-Path $wtLocal 'settings.json')) + if ($wtContent -ne '{"original": "newest"}') { $errors += "WT restore: expected backup content, got: $wtContent" } + + # All backups should be gone + $remainingBaks = Get-ChildItem $wtLocal -Filter '*.bak' -ErrorAction SilentlyContinue + if ($remainingBaks) { $errors += "WT backups: $($remainingBaks.Count) backup(s) still exist" } + + # Cache: omp-init, zoxide-init, theme.json, terminal-config.json should be gone + foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json')) { + if (Test-Path (Join-Path $cacheDir $f)) { $errors += "Cache: $f still exists" } + } + + # Cache: user-settings.json should be PRESERVED (no -RemoveUserData) + if (-not (Test-Path (Join-Path $cacheDir 'user-settings.json'))) { $errors += 'Cache: user-settings.json was deleted (should be preserved)' } + + # Cache: plugins/ (user-authored scripts) should be PRESERVED (no -RemoveUserData) + if (-not (Test-Path (Join-Path $pluginsDir 'myplugin.ps1'))) { $errors += 'Cache: plugins/myplugin.ps1 was deleted (should be preserved without -RemoveUserData)' } + + # Profile files should be gone from both dirs + foreach ($d in @($ps7Dir, $ps5Dir)) { + $pf = Join-Path $d 'Microsoft.PowerShell_profile.ps1' + if (Test-Path $pf) { $errors += "Profile: $pf still exists" } + } + + # profile_user.ps1 should be PRESERVED (no -RemoveUserData) + foreach ($d in @($ps7Dir, $ps5Dir)) { + $uf = Join-Path $d 'profile_user.ps1' + if (-not (Test-Path $uf)) { $errors += "User profile: $uf was deleted (should be preserved)" } + } + + # ===== TEST B: Recreate and test -RemoveUserData ===== + # Recreate the files that were deleted + [System.IO.File]::WriteAllText((Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1'), '# profile', [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText((Join-Path $ps5Dir 'Microsoft.PowerShell_profile.ps1'), '# profile', [System.Text.UTF8Encoding]::new($false)) + + Uninstall-Profile -RemoveUserData -Confirm:$false + + # user-settings.json should now be gone + if (Test-Path (Join-Path $cacheDir 'user-settings.json')) { $errors += 'RemoveUserData: user-settings.json still exists' } + + # plugins/ should now be gone with -RemoveUserData + if (Test-Path $pluginsDir) { $errors += 'RemoveUserData: plugins/ still exists' } + + # profile_user.ps1 should now be gone from both dirs + foreach ($d in @($ps7Dir, $ps5Dir)) { + $uf = Join-Path $d 'profile_user.ps1' + if (Test-Path $uf) { $errors += "RemoveUserData: $uf still exists" } + } + + # Cache dir itself should be gone (empty after full removal) + if ((Test-Path $cacheDir) -and (Get-ChildItem $cacheDir -ErrorAction SilentlyContinue)) { + $leftover = (Get-ChildItem $cacheDir).Name -join ', ' + $errors += "Cache dir not empty: $leftover" + } +} +finally { + # 4. Restore real env + $env:LOCALAPPDATA = $origLocal + $global:PROFILE = $origProfile + # Cleanup sandbox + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($errors) { + $errors | ForEach-Object { Write-Host "ASSERT: $_" -ForegroundColor Red } + exit 1 +} +Write-Host 'All sandbox assertions passed' +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $sandboxOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -ProfileSource $profilePath 2>&1 + $sandboxExit = $LASTEXITCODE + # Show any assertion failures from subprocess + $assertLines = @($sandboxOutput | Where-Object { $_ -match 'ASSERT:' }) + if ($assertLines) { $assertLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } } + if ($sandboxExit -ne 0) { throw "Sandbox assertions failed" } + Write-Result 'Uninstall sandbox: core' 'PASS' +} +catch { Write-Result 'Uninstall sandbox: core' 'FAIL' $_.Exception.Message } +finally { + $env:CI = $null + Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue +} + +# ------------------------------------------------------- +# 20. Uninstall-Profile sandbox: -All (everything removed) +# ------------------------------------------------------- +Write-Host '[20/26] Uninstall sandbox: -All' -ForegroundColor Cyan +try { + $env:CI = 'true' + $sandboxScript = Join-Path $env:TEMP "uninstall-all-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($ProfileSource) +$ErrorActionPreference = 'Stop' + +$env:CI = 'true' +. $ProfileSource + +$sb = Join-Path $env:TEMP "psp-sandbox-all-$([System.IO.Path]::GetRandomFileName())" + +$wtLocal = Join-Path $sb 'Local\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' +$cacheDir = Join-Path $sb 'Local\PowerShellProfile' +$ps7Dir = Join-Path $sb 'Documents\PowerShell' +$ps5Dir = Join-Path $sb 'Documents\WindowsPowerShell' + +New-Item -ItemType Directory -Path $wtLocal, $cacheDir, $ps7Dir, $ps5Dir -Force | Out-Null + +[System.IO.File]::WriteAllText((Join-Path $wtLocal 'settings.json'), '{"modified": true}', [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText((Join-Path $wtLocal 'settings.json.20240601-120000.bak'), '{"original": true}', [System.Text.UTF8Encoding]::new($false)) + +foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json')) { + [System.IO.File]::WriteAllText((Join-Path $cacheDir $f), "# $f", [System.Text.UTF8Encoding]::new($false)) +} + +foreach ($d in @($ps7Dir, $ps5Dir)) { + [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), '# profile', [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user', [System.Text.UTF8Encoding]::new($false)) +} + +$origLocal = $env:LOCALAPPDATA +$origProfile = $PROFILE +$env:LOCALAPPDATA = Join-Path $sb 'Local' +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$errors = @() +try { + # -All removes everything (except tools/fonts which need winget/admin) + Uninstall-Profile -All -Confirm:$false + + # WT restored + $wtContent = [System.IO.File]::ReadAllText((Join-Path $wtLocal 'settings.json')) + if ($wtContent -ne '{"original": true}') { $errors += "WT restore failed: $wtContent" } + + # No backups + if (Get-ChildItem $wtLocal -Filter '*.bak' -ErrorAction SilentlyContinue) { $errors += 'Backups remain' } + + # ALL cache files gone (including user-settings.json) + foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json')) { + if (Test-Path (Join-Path $cacheDir $f)) { $errors += "Cache: $f still exists" } + } + + # ALL profile files gone (including profile_user.ps1) + foreach ($d in @($ps7Dir, $ps5Dir)) { + foreach ($f in @('Microsoft.PowerShell_profile.ps1', 'profile_user.ps1')) { + if (Test-Path (Join-Path $d $f)) { $errors += "$f still in $d" } + } + } +} +finally { + $env:LOCALAPPDATA = $origLocal + $global:PROFILE = $origProfile + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($errors) { + $errors | ForEach-Object { Write-Host "ASSERT: $_" -ForegroundColor Red } + exit 1 +} +Write-Host 'All -All sandbox assertions passed' +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $sandboxOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -ProfileSource $profilePath 2>&1 + $sandboxExit = $LASTEXITCODE + $assertLines = @($sandboxOutput | Where-Object { $_ -match 'ASSERT:' }) + if ($assertLines) { $assertLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } } + if ($sandboxExit -ne 0) { throw "Sandbox assertions failed" } + Write-Result 'Uninstall sandbox: -All' 'PASS' +} +catch { Write-Result 'Uninstall sandbox: -All' 'FAIL' $_.Exception.Message } +finally { + $env:CI = $null + Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue +} + +# ------------------------------------------------------- +# 21. Lifecycle: install -> uninstall -> reinstall +# ------------------------------------------------------- +Write-Host '[21/26] Lifecycle: install -> uninstall -> reinstall' -ForegroundColor Cyan +try { + $sandboxScript = Join-Path $env:TEMP "lifecycle-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($RepoRoot) +$ErrorActionPreference = 'Stop' + +# Parse setup.ps1 AST to extract functions +$setupFile = Join-Path $RepoRoot 'setup.ps1' +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($setupFile, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "setup.ps1 parse errors: $($parseErrors.Count)" } +$fnDefs = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) +foreach ($fn in $fnDefs) { Invoke-Expression $fn.Extent.Text } + +# ===== Create sandbox ===== +$sb = Join-Path $env:TEMP "psp-lifecycle-$([System.IO.Path]::GetRandomFileName())" +$cacheDir = Join-Path $sb 'Local\PowerShellProfile' +$ps7Dir = Join-Path $sb 'Documents\PowerShell' +$ps5Dir = Join-Path $sb 'Documents\WindowsPowerShell' +$wtLocal = Join-Path $sb 'Local\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' +New-Item -ItemType Directory -Path $cacheDir, $ps7Dir, $ps5Dir, $wtLocal -Force | Out-Null +$configCachePath = $cacheDir + +# Minimal WT settings.json +$wtOriginal = '{"profiles":{"defaults":{"font":{"face":"Consolas"}},"list":[]},"schemes":[],"actions":[]}' +[System.IO.File]::WriteAllText((Join-Path $wtLocal 'settings.json'), $wtOriginal, [System.Text.UTF8Encoding]::new($false)) + +$origLocal = $env:LOCALAPPDATA +$origProfile = $PROFILE +$env:LOCALAPPDATA = Join-Path $sb 'Local' +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$errors = @() +try { + # ===== PHASE 1: INSTALL ===== + Write-Host ' --- Phase 1: Install ---' + $profileSrc = Join-Path $RepoRoot 'Microsoft.PowerShell_profile.ps1' + foreach ($d in @($ps7Dir, $ps5Dir)) { + Copy-Item $profileSrc (Join-Path $d 'Microsoft.PowerShell_profile.ps1') + } + + # Download configs + Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/theme.json' -OutFile (Join-Path $cacheDir 'theme.json') + Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/terminal-config.json' -OutFile (Join-Path $cacheDir 'terminal-config.json') + + # OMP theme + $themeJson = Get-Content (Join-Path $cacheDir 'theme.json') -Raw | ConvertFrom-Json + Invoke-DownloadWithRetry -Uri $themeJson.theme.url -OutFile (Join-Path $cacheDir "$($themeJson.theme.name).omp.json") + + # user-settings.json + profile_user.ps1 + [System.IO.File]::WriteAllText((Join-Path $cacheDir 'user-settings.json'), '{}', [System.Text.UTF8Encoding]::new($false)) + foreach ($d in @($ps7Dir, $ps5Dir)) { + [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user', [System.Text.UTF8Encoding]::new($false)) + } + + # WT backup (simulates what Update-Profile does) + $bakFile = Join-Path $wtLocal 'settings.json.20240601-120000.bak' + Copy-Item (Join-Path $wtLocal 'settings.json') $bakFile + + # Verify install + $installFiles = @( + (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1'), + (Join-Path $ps5Dir 'Microsoft.PowerShell_profile.ps1'), + (Join-Path $cacheDir 'theme.json'), + (Join-Path $cacheDir 'terminal-config.json'), + (Join-Path $cacheDir 'user-settings.json'), + (Join-Path $ps7Dir 'profile_user.ps1'), + (Join-Path $ps5Dir 'profile_user.ps1') + ) + $missingInstall = @($installFiles | Where-Object { -not (Test-Path $_) }) + if ($missingInstall) { $errors += "Install missing: $($missingInstall -join ', ')" } + else { Write-Host ' OK Install: all files created' } + + # Load profile to get Uninstall-Profile + $env:CI = 'true' + . (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1') + if (-not (Get-Command 'Uninstall-Profile' -ErrorAction SilentlyContinue)) { throw 'Uninstall-Profile not available after install' } + Write-Host ' OK Install: profile loaded, Uninstall-Profile available' + + # ===== PHASE 2: UNINSTALL (-All) ===== + Write-Host ' --- Phase 2: Uninstall -All ---' + Uninstall-Profile -All -Confirm:$false + + # Verify everything removed + foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json')) { + if (Test-Path (Join-Path $cacheDir $f)) { $errors += "After uninstall: $f still in cache" } + } + # OMP theme file should be gone + $ompFiles = Get-ChildItem $cacheDir -Filter '*.omp.json' -ErrorAction SilentlyContinue + if ($ompFiles) { $errors += "After uninstall: OMP theme still in cache" } + + foreach ($d in @($ps7Dir, $ps5Dir)) { + foreach ($f in @('Microsoft.PowerShell_profile.ps1', 'profile_user.ps1')) { + if (Test-Path (Join-Path $d $f)) { $errors += "After uninstall: $f still in $d" } + } + } + + # WT should be restored (backup content) + $wtContent = [System.IO.File]::ReadAllText((Join-Path $wtLocal 'settings.json')) + if ($wtContent -ne $wtOriginal) { Write-Host " WARN WT content differs (backup was modified copy, not original)" } + + # No backups should remain + $baks = Get-ChildItem $wtLocal -Filter '*.bak' -ErrorAction SilentlyContinue + if ($baks) { $errors += "After uninstall: $($baks.Count) backup(s) remain" } + + Write-Host ' OK Uninstall: all files removed' + + # ===== PHASE 3: REINSTALL ===== + Write-Host ' --- Phase 3: Reinstall ---' + # Recreate cache dir (uninstall may have cleaned it) + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + $configCachePath = $cacheDir + + foreach ($d in @($ps7Dir, $ps5Dir)) { + New-Item -ItemType Directory -Path $d -Force | Out-Null + Copy-Item $profileSrc (Join-Path $d 'Microsoft.PowerShell_profile.ps1') + } + + Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/theme.json' -OutFile (Join-Path $cacheDir 'theme.json') + Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/terminal-config.json' -OutFile (Join-Path $cacheDir 'terminal-config.json') + $themeJson = Get-Content (Join-Path $cacheDir 'theme.json') -Raw | ConvertFrom-Json + Invoke-DownloadWithRetry -Uri $themeJson.theme.url -OutFile (Join-Path $cacheDir "$($themeJson.theme.name).omp.json") + [System.IO.File]::WriteAllText((Join-Path $cacheDir 'user-settings.json'), '{}', [System.Text.UTF8Encoding]::new($false)) + foreach ($d in @($ps7Dir, $ps5Dir)) { + [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user', [System.Text.UTF8Encoding]::new($false)) + } + + # Verify reinstall + $missingReinstall = @($installFiles | Where-Object { -not (Test-Path $_) }) + if ($missingReinstall) { $errors += "Reinstall missing: $($missingReinstall -join ', ')" } + else { Write-Host ' OK Reinstall: all files recreated' } + + # Reload profile and verify it works + . (Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1') + if (-not (Get-Command 'Show-Help' -ErrorAction SilentlyContinue)) { $errors += 'Reinstall: profile failed to load' } + if (-not (Get-Command 'Uninstall-Profile' -ErrorAction SilentlyContinue)) { $errors += 'Reinstall: Uninstall-Profile missing' } + Write-Host ' OK Reinstall: profile loads correctly' +} +finally { + $env:LOCALAPPDATA = $origLocal + $global:PROFILE = $origProfile + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($errors) { + $errors | ForEach-Object { Write-Host "ASSERT: $_" -ForegroundColor Red } + exit 1 +} +Write-Host 'Lifecycle test passed (install -> uninstall -> reinstall)' +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $lcOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -RepoRoot $repoRoot 2>&1 + foreach ($line in $lcOutput) { + $color = if ($line -match '^\s+OK') { 'Green' } elseif ($line -match 'ASSERT:|FAIL') { 'Red' } elseif ($line -match 'WARN') { 'Yellow' } else { 'White' } + Write-Host " $line" -ForegroundColor $color + } + $assertLines = @($lcOutput | Where-Object { $_ -match 'ASSERT:' }) + if ($assertLines) { $assertLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } } + if ($LASTEXITCODE -ne 0) { throw 'Lifecycle test failed' } + Write-Result 'Lifecycle: install -> uninstall -> reinstall' 'PASS' +} +catch { Write-Result 'Lifecycle: install -> uninstall -> reinstall' 'FAIL' $_.Exception.Message } +finally { Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue } + +# ------------------------------------------------------- +# 22. Execute every command (sandbox) +# ------------------------------------------------------- +Write-Host '[22/26] Execute every command' -ForegroundColor Cyan +try { + $sandboxScript = Join-Path $env:TEMP "fn-exec-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($ProfileSource) +$ErrorActionPreference = 'Continue' +$env:CI = 'true' +. $ProfileSource + +$ok = 0; $fail = 0; $skip = 0; $netFail = 0 +function T { + param([string]$N, [scriptblock]$C, [string]$S) + if ($S) { Write-Host " SKIP $N ($S)"; $script:skip++; return } + try { + $null = & { $ErrorActionPreference = 'Stop'; & $C } 2>&1 + Write-Host " OK $N"; $script:ok++ + } + catch { + $msg = $_.Exception.Message + if ($msg -match 'Timeout|timed out|HttpClient|Unable to connect') { + Write-Host " NET $N ($msg)"; $script:netFail++ + } else { + Write-Host " FAIL $N ($msg)"; $script:fail++ + } + } +} + +# --- Temp workspace --- +$ws = Join-Path $env:TEMP "psp-exec-$([System.IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory $ws -Force | Out-Null +$tf = Join-Path $ws 'sample.txt' +$tf2 = Join-Path $ws 'sample2.txt' +$jf = Join-Path $ws 'sample.json' +$zf = Join-Path $ws 'sample.zip' +$sd = Join-Path $ws 'subdir' +New-Item -ItemType Directory $sd -Force | Out-Null +[System.IO.File]::WriteAllText($tf, "old line1`nold line2`nline3`nline4`nline5`nline6`nline7`nline8`nline9`nline10", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($tf2, "hello world", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($jf, '{"name":"test","nested":{"a":1}}', [System.Text.UTF8Encoding]::new($false)) +Compress-Archive -Path $tf -DestinationPath $zf -Force + +# Temp git repo for git commands +$gr = Join-Path $ws 'gitrepo' +New-Item -ItemType Directory $gr -Force | Out-Null +Push-Location $gr +git init --quiet 2>$null +git config user.email "test@test.com" 2>$null +git config user.name "Test" 2>$null +[System.IO.File]::WriteAllText((Join-Path $gr 'readme.txt'), 'init', [System.Text.UTF8Encoding]::new($false)) +git add . 2>$null +git commit -m "init" --quiet 2>$null + +# ===== PROFILE & UPDATES ===== +T 'Show-Help' { Show-Help } +T 'path' { path } +T 'prompt' { $p = prompt; if ([string]::IsNullOrWhiteSpace($p)) { throw 'prompt returned empty string' } } +T 'Get-SystemBootTime' { $b = Get-SystemBootTime; if (-not $b) { throw 'no boot time returned' } } +T 'reload' $null 'reloads profile mid-test' +T 'Edit-Profile' $null 'opens editor UI' +T 'ep' $null 'opens editor UI' +T 'edit' $null 'opens editor UI' +T 'Update-Profile' { + $cmd = Get-Command Update-Profile + foreach ($p in @('Force', 'SkipHashCheck', 'ExpectedSha256', 'WhatIf')) { + if (-not $cmd.Parameters.ContainsKey($p)) { throw "Update-Profile missing parameter: $p" } + } + # -WhatIf must prevent Phase 1 downloads via the ShouldProcess gate. + $filters = @('psp-profile-*.ps1', 'psp-theme-*.json', 'psp-terminal-*.json') + $pre = foreach ($f in $filters) { Get-ChildItem -Path $env:TEMP -Filter $f -ErrorAction SilentlyContinue } + Update-Profile -WhatIf -Confirm:$false | Out-Null + $post = foreach ($f in $filters) { Get-ChildItem -Path $env:TEMP -Filter $f -ErrorAction SilentlyContinue } + if (@($post).Count -gt @($pre).Count) { throw 'Update-Profile -WhatIf leaked temp files' } +} +T 'Update-PowerShell' { + # Safe early-exit paths: PS5 prints guidance and returns; PS7 without winget warns and returns. + Update-PowerShell *> $null +} +T 'Update-Tools' { + if (Get-Command winget -ErrorAction SilentlyContinue) { + # winget present - would mutate installs; just verify command exists + if (-not (Get-Command Update-Tools)) { throw 'Update-Tools missing' } + } + else { + Update-Tools *> $null + } +} +T 'Clear-ProfileCache' $null 'destructive to real cache' +T 'Clear-Cache' $null 'destructive to real cache' +T 'Uninstall-Profile' $null 'tested in sandbox 19/20' +T 'Invoke-ProfileWizard' { + # -WhatIf must prevent the network download via the ShouldProcess gate. + $pre = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*.ps1' -ErrorAction SilentlyContinue) + Invoke-ProfileWizard -WhatIf -Confirm:$false | Out-Null + $post = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*.ps1' -ErrorAction SilentlyContinue) + if ($post.Count -gt $pre.Count) { throw "Invoke-ProfileWizard -WhatIf leaked temp files" } + $cmd = Get-Command Invoke-ProfileWizard + foreach ($p in @('Resume', 'NoElevate', 'ExpectedSha256', 'BundleExpectedSha256', 'SkipHashCheck', 'WhatIf')) { + if (-not $cmd.Parameters.ContainsKey($p)) { throw "Invoke-ProfileWizard missing parameter: $p" } + } +} +T 'Reconfigure-Profile' { + $alias = Get-Alias Reconfigure-Profile -ErrorAction SilentlyContinue + if (-not $alias) { throw 'Reconfigure-Profile alias missing' } + if ($alias.ResolvedCommandName -ne 'Invoke-ProfileWizard') { throw "Alias resolves to $($alias.ResolvedCommandName)" } +} + +# ===== GIT ===== +T 'gs' { gs } +T 'ga' { [System.IO.File]::WriteAllText((Join-Path $gr 'new.txt'), 'x', [System.Text.UTF8Encoding]::new($false)); ga } +T 'gc' { gc "test commit" } +T 'gcom' { [System.IO.File]::WriteAllText((Join-Path $gr 'new2.txt'), 'y', [System.Text.UTF8Encoding]::new($false)); gcom "test gcom" } +T 'gpush' $null 'no remote configured' +T 'gpull' $null 'no remote configured' +T 'gcl' $null 'network + clones repo' +T 'lazyg' $null 'no remote configured' +T 'g' $null 'needs zoxide github dir' +Pop-Location + +# ===== FILES & NAVIGATION ===== +T 'ls' { ls $ws } +T 'la' { la $ws } +T 'll' { ll $ws } +T 'lt' { lt $ws } +T 'cat' { cat $tf } +T 'ff' { Push-Location $ws; ff "sample.txt"; Pop-Location } +T 'touch' { touch (Join-Path $ws 'touched.txt') } +T 'nf' { touch (Join-Path $ws 'newfile.txt') } +T 'mkcd' { $d = Join-Path $ws 'mkcdtest'; mkcd $d; Pop-Location } +T 'head' { head $tf 3 } +T 'tail' { tail $tf 3 } +T 'file' { file $tf } +T 'sizeof' { sizeof $ws } +T 'trash' { $t = Join-Path $ws 'trashme.txt'; [System.IO.File]::WriteAllText($t, 'bye', [System.Text.UTF8Encoding]::new($false)); trash $t } +T 'extract' { $ed = Join-Path $ws 'extracted'; New-Item -ItemType Directory $ed -Force | Out-Null; Push-Location $ed; extract $zf; Pop-Location } +T 'docs' { docs } +T 'dtop' { dtop } +T 'cdh' { + $before = Get-Location + try { + $sub = Join-Path $ws 'cdh-t' + New-Item -ItemType Directory -Path $sub -Force | Out-Null + Set-Location $sub; Invoke-PromptStage + Set-Location $ws; Invoke-PromptStage + $out = cdh | Out-String + if ($out -notmatch 'cdh-t') { throw 'cdh missing seeded entry' } + } + finally { Set-Location $before } +} +T 'cdb' { + $before = Get-Location + try { + $sub = Join-Path $ws 'cdb-t' + New-Item -ItemType Directory -Path $sub -Force | Out-Null + Set-Location $sub; Invoke-PromptStage + Set-Location $ws; Invoke-PromptStage + cdb 1 + if ((Get-Location).Path -ne $sub) { throw 'cdb 1 did not navigate back' } + } + finally { Set-Location $before } +} +T 'duration' { + Get-Date | Out-Null + $out = duration | Out-String + if ([string]::IsNullOrWhiteSpace($out)) { throw 'duration produced no output' } +} +T 'Test-ProfileHealth' { + $report = Test-ProfileHealth + if (-not $report) { throw 'Test-ProfileHealth returned no rows' } +} +T 'psp-doctor' { + $alias = Get-Alias psp-doctor -ErrorAction SilentlyContinue + if (-not $alias) { throw 'psp-doctor alias missing' } +} +T 'bak' { bak $tf } + +# ===== UNIX-LIKE ===== +T 'grep' { grep "line" $ws } +T 'sed' { $sf = Join-Path $ws 'sedtest.txt'; [System.IO.File]::WriteAllText($sf, 'foo bar foo', [System.Text.UTF8Encoding]::new($false)); sed $sf "foo" "baz" } +T 'which' { which pwsh } +T 'pgrep' { pgrep "pwsh" } +T 'pkill' $null 'destructive - kills processes' +T 'export' { export "PSP_TEST_VAR" "testvalue" } + +# ===== SYSTEM & NETWORK ===== +T 'admin' $null 'opens elevated terminal' +T 'su' $null 'opens elevated terminal' +T 'pubip' { pubip } +T 'localip' { localip } +T 'uptime' { uptime } +T 'sysinfo' { sysinfo } +T 'df' { df } +T 'flushdns' $null 'requires admin' +T 'ports' { ports } +T 'checkport' { checkport "dns.google" 443 } +T 'portscan' { portscan "dns.google" -Ports @(53, 443) } +T 'tlscert' { tlscert "google.com" } +T 'ipinfo' { ipinfo "8.8.8.8" } +T 'whois' { whois "example.com" } +T 'nslook' { nslook "google.com" } +T 'env' { env "PATH" } +T 'svc' { svc "idle" -Count 1 } +T 'eventlog' { eventlog 1 } +T 'weather' { weather "Oslo" } +T 'speedtest' $null 'takes 30s+ download' +T 'wifipass' $null 'requires admin/netsh' +T 'hosts' $null 'opens elevated editor' +T 'winutil' { + $marker = Join-Path $ws 'winutil-ran.txt' + $expectedHash = ('AB' * 32) + try { + function Invoke-RestMethod { + param( + [string]$Uri, + [string]$OutFile, + [int]$TimeoutSec, + [switch]$UseBasicParsing, + $ErrorAction + ) + $utf8 = [System.Text.UTF8Encoding]::new($false) + $markerLiteral = $marker -replace "'", "''" + $scriptBody = "[System.IO.File]::WriteAllText('$markerLiteral', 'ran', [System.Text.UTF8Encoding]::new(`$false))" + [System.IO.File]::WriteAllText($OutFile, $scriptBody, $utf8) + } + function Get-FileHash { + param( + [string]$LiteralPath, + [string]$Algorithm + ) + [PSCustomObject]@{ Hash = $expectedHash } + } + + winutil + if (Test-Path $marker) { throw 'winutil executed without explicit opt-in' } + + winutil -Force -WhatIf + if (Test-Path $marker) { throw 'winutil executed under -WhatIf' } + + winutil -ExpectedSha256 $expectedHash -Confirm:$false + if (-not (Test-Path $marker)) { throw 'winutil did not execute after hash match + explicit confirmation bypass' } + } + finally { + Remove-Item Function:\Invoke-RestMethod -ErrorAction SilentlyContinue + Remove-Item Function:\Get-FileHash -ErrorAction SilentlyContinue + Remove-Item $marker -Force -ErrorAction SilentlyContinue + } +} +T 'harden' { + $script:startedHarden = $null + try { + function Get-ExternalCommandPath { + param([string]$CommandName) + if ($CommandName -eq 'hss.exe') { return 'C:\Tools\hss.exe' } + return $null + } + function Start-Process { + param([string]$FilePath) + $script:startedHarden = $FilePath + } + + harden -WhatIf + if ($script:startedHarden) { throw 'harden launched tool under -WhatIf' } + + harden -Confirm:$false + if ($script:startedHarden -ne 'C:\Tools\hss.exe') { throw "unexpected launch target: $script:startedHarden" } + } + finally { + Remove-Item Function:\Get-ExternalCommandPath -ErrorAction SilentlyContinue + Remove-Item Function:\Start-Process -ErrorAction SilentlyContinue + Remove-Variable -Name startedHarden -Scope Script -ErrorAction SilentlyContinue + } +} + +# ===== SECURITY & CRYPTO ===== +$hashOut = $null +T 'hash' { $script:hashOut = hash $tf; $script:hashOut } +T 'hash MD5' { hash $tf -Algorithm MD5 } +T 'checksum' { if ($script:hashOut) { $h = ($script:hashOut | Out-String).Trim().Split(' ')[-1]; checksum $tf $h } else { throw 'no hash' } } +T 'genpass' { genpass 16 } +T 'b64' { b64 "hello world" } +T 'b64d' { b64d "aGVsbG8gd29ybGQ=" } +T 'jwtd' { jwtd "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" } +T 'Test-ProfileHistorySafeLine' { + if (Test-ProfileHistorySafeLine 'pwnd hunter2') { throw 'pwnd input allowed into history' } + if (Test-ProfileHistorySafeLine 'jwtd eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig') { throw 'jwt decode allowed into history' } + # pwnd/jwtd must be scrubbed wherever the command can start, not just at line start + foreach ($leaky in @('$p = pwnd hunter2', 'cls; pwnd hunter2', 'history | pwnd hunter2', '1..3 | % { pwnd s }')) { + if (Test-ProfileHistorySafeLine $leaky) { throw "pwnd not scrubbed in chained form: $leaky" } + } + # value-shaped secrets caught even with no keyword present + foreach ($secret in @('git remote add o https://ghp_abcdefghij1234567890ABCDEFGH@github.com', 'export K=AKIAIOSFODNN7EXAMPLE', 'mysql -uroot -pHunter2024', 'curl https://user:s3cr3t@example.com')) { + if (Test-ProfileHistorySafeLine $secret) { throw "value-shaped secret allowed into history: $secret" } + } + # benign lines must NOT be blocked (no false positives on common flags/URLs) + foreach ($safe in @('git status --short', 'mkdir -p src/lib', 'docker run -p 8080:80 nginx', 'git clone https://github.com/foo/bar')) { + if (-not (Test-ProfileHistorySafeLine $safe)) { throw "safe command blocked from history: $safe" } + } +} +T 'uuid' { uuid } +T 'epoch' { epoch } +T 'epoch 0' { epoch 0 } +T 'urlencode' { urlencode "hello world" } +T 'urldecode' { urldecode "hello%20world" } +T 'vtscan' $null 'needs API key; -Upload submits file content' +T 'vt' { + if (Get-Command vt.exe -ErrorAction SilentlyContinue) { vt --help } + else { vt } +} + +# ===== DEVELOPER ===== +T 'killport' $null 'destructive - kills process' +T 'Stop-ListeningPort' $null 'interactive fzf picker' +T 'killports' $null 'alias to Stop-ListeningPort' +T 'Find-FileLocker' { + $lf = Join-Path $ws 'locktest.txt' + [System.IO.File]::WriteAllText($lf, 'x', [System.Text.UTF8Encoding]::new($false)) + $s = [System.IO.File]::Open($lf, 'Open', 'ReadWrite', 'None') + try { + $r = @(Find-FileLocker $lf) + if (-not ($r | Where-Object PID -eq $PID)) { throw 'did not report self PID' } + } finally { $s.Close(); $s.Dispose() } +} +T 'Stop-StuckProcess' $null 'destructive - kills processes' +T 'Remove-LockedItem' $null 'destructive - kills + deletes' +T 'http' { http "https://httpbin.org/get" } +T 'prettyjson' { prettyjson $jf } +T 'hb' { hb $tf } +T 'timer' { timer { Start-Sleep -Milliseconds 10 } } +T 'watch' $null 'infinite loop' + +# ===== DOCKER ===== +$hasDocker = [bool](Get-Command docker -ErrorAction SilentlyContinue) +$dockerRunning = $false +if ($hasDocker) { $null = docker info 2>&1; $dockerRunning = ($LASTEXITCODE -eq 0) } +$dockerSkip = if (-not $hasDocker) { 'docker not installed' } elseif (-not $dockerRunning) { 'docker daemon not running' } else { $null } +T 'dps' { dps } $dockerSkip +T 'dpa' { dpa } $dockerSkip +T 'dimg' { dimg } $dockerSkip +T 'dlogs' $null 'needs running container' +T 'dex' $null 'needs running container' +T 'dstop' $null 'destructive' +T 'dprune' $null 'destructive' + +# ===== SSH & REMOTE ===== +$hasSsh = [bool](Get-Command ssh -ErrorAction SilentlyContinue) +T 'Copy-SshKey' $null 'needs remote host' +T 'ssh-copy-key' $null 'needs remote host' +T 'keygen' $null 'creates keys on disk' +T 'rdp' $null 'opens RDP UI' + +# ===== CLIPBOARD ===== +T 'cpy' { cpy "psp-test-clip" } +T 'pst' { $r = pst; if ($r -ne "psp-test-clip") { throw "expected psp-test-clip, got: $r" } } +T 'icb' $null 'PSReadLine handler only' +T 'Invoke-Clipboard' $null 'PSReadLine handler only' + +# ===== SSH WRAPPER ===== +T 'ssh' $null 'wraps ssh.exe; real TCP connect' +T 'wsl' $null 'wraps wsl.exe; launches distro shell' +T 'Get-WslDistro' { Get-WslDistro | Out-Null } +T 'Enter-WslHere' $null 'opens interactive WSL shell' +T 'wsl-here' $null 'alias to Enter-WslHere' +T 'ConvertTo-WslPath' $null 'requires WSL distro' +T 'ConvertTo-WindowsPath' $null 'requires WSL distro' +T 'Stop-Wsl' $null 'destructive: terminates distros' +T 'Get-WslIp' $null 'requires running distro' +T 'Get-WslFile' $null 'requires running distro' +T 'Show-WslTree' $null 'requires running distro' +T 'wsl-tree' $null 'alias to Show-WslTree' +T 'Open-WslExplorer' $null 'opens Windows Explorer' +T 'wsl-explorer' $null 'alias to Open-WslExplorer' + +# ===== SYSADMIN ===== +T 'journal' { journal -Count 2 } +T 'lsblk' { lsblk } +T 'htop' $null 'launches TUI' +T 'mtr' $null 'long traceroute+ping loop' +T 'fwallow' { + $origIsAdmin = $script:isAdmin + $script:isAdmin = $true + $script:fwCalls = @() + try { + function New-NetFirewallRule { + param( + [string]$DisplayName, + [string]$Direction, + [string]$Action, + [string]$Protocol, + [int]$LocalPort + ) + $script:fwCalls += [PSCustomObject]@{ + DisplayName = $DisplayName + Direction = $Direction + Action = $Action + Protocol = $Protocol + LocalPort = $LocalPort + } + } + + fwallow -Name 'PSP Test Allow' -Port 443 -WhatIf + if ($script:fwCalls.Count -ne 0) { throw 'fwallow changed firewall under -WhatIf' } + + fwallow -Name 'PSP Test Allow' -Port 443 -Confirm:$false + if ($script:fwCalls.Count -ne 1) { throw "fwallow expected 1 rule, got $($script:fwCalls.Count)" } + if ($script:fwCalls[0].Action -ne 'Allow') { throw "fwallow recorded wrong action: $($script:fwCalls[0].Action)" } + } + finally { + $script:isAdmin = $origIsAdmin + Remove-Item Function:\New-NetFirewallRule -ErrorAction SilentlyContinue + Remove-Variable -Name fwCalls -Scope Script -ErrorAction SilentlyContinue + } +} +T 'fwblock' { + $origIsAdmin = $script:isAdmin + $script:isAdmin = $true + $script:fwCalls = @() + try { + function New-NetFirewallRule { + param( + [string]$DisplayName, + [string]$Direction, + [string]$Action, + [string]$Protocol, + [int]$LocalPort + ) + $script:fwCalls += [PSCustomObject]@{ + DisplayName = $DisplayName + Direction = $Direction + Action = $Action + Protocol = $Protocol + LocalPort = $LocalPort + } + } + + fwblock -Name 'PSP Test Block' -Port 53 -WhatIf + if ($script:fwCalls.Count -ne 0) { throw 'fwblock changed firewall under -WhatIf' } + + fwblock -Name 'PSP Test Block' -Port 53 -Confirm:$false + if ($script:fwCalls.Count -ne 1) { throw "fwblock expected 1 rule, got $($script:fwCalls.Count)" } + if ($script:fwCalls[0].Action -ne 'Block') { throw "fwblock recorded wrong action: $($script:fwCalls[0].Action)" } + } + finally { + $script:isAdmin = $origIsAdmin + Remove-Item Function:\New-NetFirewallRule -ErrorAction SilentlyContinue + Remove-Variable -Name fwCalls -Scope Script -ErrorAction SilentlyContinue + } +} + +# ===== CYBERSEC ===== +T 'nscan' $null 'requires nmap' +T 'sigcheck' { sigcheck (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') } +T 'ads' { $a = Join-Path $ws 'ads.txt'; [System.IO.File]::WriteAllText($a, 'x', [System.Text.UTF8Encoding]::new($false)); Set-Content -LiteralPath $a -Stream 'zone' -Value 'marker'; ads $a } +T 'defscan' $null 'triggers Defender scan' +T 'pwnd' { pwnd 'password' } +T 'certcheck' { certcheck 'example.com' } +T 'entropy' { entropy $tf } + +# ===== DEVELOPER+ ===== +T 'serve' $null 'long-running HTTP server' +T 'gitignore' { Push-Location $ws; try { gitignore 'python' } finally { Pop-Location } } +T 'gcof' $null 'interactive fzf picker' +T 'envload' { $ef = Join-Path $ws '.env'; [System.IO.File]::WriteAllText($ef, 'PSP_TST=ok', [System.Text.UTF8Encoding]::new($false)); envload $ef } +T 'tldr' { tldr 'ls' } +T 'repeat' { $script:rc=0; repeat 3 { $script:rc++ }; if ($script:rc -ne 3) { throw "expected 3, got $script:rc" } } +T 'mkvenv' $null 'creates venv dir' + +# ===== DETECTION / AST ===== +T 'outline' { outline $ProfileSource | Out-Null } +T 'psym' { psym 'Update-Profile' (Split-Path $ProfileSource) | Out-Null } +T 'lint' $null 'requires PSScriptAnalyzer' +T 'Find-DeadCode' { $f = Join-Path $ws 'dc.ps1'; [System.IO.File]::WriteAllText($f, "function a { `$b = 1; a }", [System.Text.UTF8Encoding]::new($false)); Find-DeadCode $f | Out-Null } +T 'Test-Profile' { Test-Profile | Out-Null } +T 'Get-PwshVersions' { Get-PwshVersions | Out-Null } +T 'modinfo' { modinfo 'PSReadLine' | Out-Null } +T 'psgrep' { psgrep 'Update-Profile' (Split-Path $ProfileSource) -Kind Function | Out-Null } + +# ===== EXTENSIBILITY ===== +T 'Register-ProfileHook' { Register-ProfileHook -EventName PrePrompt -Action { } } +T 'Register-HelpSection' { Register-HelpSection -Title 'T' -Lines @('x') } +T 'Register-ProfileCommand' { Register-ProfileCommand -Name 't' -Category 'T' -Synopsis 's' } +T 'Get-ProfileCommand' { if (@(Get-ProfileCommand).Count -lt 50) { throw 'registry too small' } } +T 'Start-ProfileTour' $null 'interactive Read-Host loop' +T 'Add-TrustedDirectory' $null 'persists to user-settings.json (tested in sandbox)' +T 'Remove-TrustedDirectory' $null 'persists to user-settings.json (tested in sandbox)' + +# ===== THEME ===== +T 'Set-TerminalBackground' $null 'persists to user-settings.json (tested in sandbox)' + +# --- Cleanup --- +Remove-Item $ws -Recurse -Force -ErrorAction SilentlyContinue + +# --- Result --- +Write-Host "" +$summary = " Functions: $ok ok, $fail fail, $netFail net-fail, $skip skip" +Write-Host $summary -ForegroundColor $(if ($fail -gt 0) { 'Red' } elseif ($netFail -gt 0) { 'Yellow' } else { 'Green' }) +if ($fail -gt 0) { exit 1 } +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $fnOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -ProfileSource $profilePath 2>&1 + # Show all output + foreach ($line in $fnOutput) { + $color = if ($line -match '^\s+OK') { 'Green' } elseif ($line -match '^\s+FAIL') { 'Red' } elseif ($line -match '^\s+NET') { 'Yellow' } elseif ($line -match '^\s+SKIP') { 'DarkGray' } else { 'White' } + Write-Host " $line" -ForegroundColor $color + } + $fnFails = @($fnOutput | Where-Object { $_ -match '^\s+FAIL' }) + if ($LASTEXITCODE -ne 0 -or $fnFails.Count -gt 0) { throw "$($fnFails.Count) function(s) failed" } + Write-Result 'Execute every command' 'PASS' +} +catch { Write-Result 'Execute every command' 'FAIL' $_.Exception.Message } +finally { Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue } + +# ===================================================================== +# SETUP & INSTALL VERIFICATION +# ===================================================================== +Write-Host '' +Write-Host '--- Setup & install verification ---' -ForegroundColor Magenta +Write-Host '' + +# ------------------------------------------------------- +# 23. setup.ps1 functions sandbox (AST extract + execute) +# ------------------------------------------------------- +Write-Host '[23/26] setup.ps1 functions sandbox' -ForegroundColor Cyan +try { + $sandboxScript = Join-Path $env:TEMP "setup-fn-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($SetupSource) +$ErrorActionPreference = 'Stop' + +# Parse setup.ps1 AST to extract functions without running install flow +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($SetupSource, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "setup.ps1 has $($parseErrors.Count) parse error(s)" } + +# Extract and define all top-level functions +$fnDefs = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false) +foreach ($fn in $fnDefs) { Invoke-Expression $fn.Extent.Text } +Write-Host " Extracted $($fnDefs.Count) functions from setup.ps1" + +# Extract $EditorCandidates assignment +$varDefs = $ast.FindAll({ + $args[0] -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $args[0].Left.Extent.Text -eq '$EditorCandidates' +}, $true) +if ($varDefs.Count -gt 0) { Invoke-Expression $varDefs[0].Extent.Text } + +$ok = 0; $fail = 0 +function T($N, $C) { + try { $null = & $C 2>&1; Write-Host " OK $N"; $script:ok++ } + catch { Write-Host " FAIL $N ($_)"; $script:fail++ } +} + +# --- Test-InternetConnection --- +T 'Test-InternetConnection' { + $r = Test-InternetConnection + if ($r -ne $true) { throw "expected true, got $r" } +} + +# --- Invoke-DownloadWithRetry (real download) --- +T 'Invoke-DownloadWithRetry' { + $tmp = Join-Path $env:TEMP "psp-dltest-$([System.IO.Path]::GetRandomFileName()).json" + try { + Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/theme.json' -OutFile $tmp + if (-not (Test-Path $tmp) -or (Get-Item $tmp).Length -eq 0) { throw 'download empty' } + $null = Get-Content $tmp -Raw | ConvertFrom-Json + } + finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } +} + +# --- Merge-JsonObject (setup.ps1 copy) --- +T 'Merge-JsonObject (setup copy)' { + $b = [PSCustomObject]@{ a = 1; n = [PSCustomObject]@{ x = 10; y = 20 } } + Merge-JsonObject $b ([PSCustomObject]@{ a = 99; n = [PSCustomObject]@{ y = 30; z = 40 } }) + if ($b.a -ne 99 -or $b.n.x -ne 10 -or $b.n.y -ne 30 -or $b.n.z -ne 40) { throw 'merge mismatch' } +} + +# --- Wizard -Resume round-trip (H2 regression): restored choices must still persist --- +# A wizard interrupted and resumed reloads $choices from JSON, which turns the [ordered] Terminal/Features +# hashtables into PSCustomObjects (no .Keys). Start-InstallWizard now rehydrates them; without that fix +# Save-WizardChoices' .Keys loops ran zero times and silently dropped every appearance/feature choice. +T 'Save-WizardChoices persists resumed terminal/feature choices' { + $choices = @{ + Terminal = [ordered]@{ opacity = 90; fontSize = 12; cursorShape = 'bar' } + Features = [ordered]@{ psfzf = $false; predictions = $true } + } + # Simulate the state-file round-trip, then the resume rehydration Start-InstallWizard now performs. + $restored = @{} + foreach ($p in (($choices | ConvertTo-Json -Depth 20 | ConvertFrom-Json).PSObject.Properties)) { $restored[$p.Name] = $p.Value } + foreach ($field in 'Terminal', 'Features') { + if ($restored[$field] -is [System.Management.Automation.PSCustomObject]) { + $ht = [ordered]@{} + foreach ($pp in $restored[$field].PSObject.Properties) { $ht[$pp.Name] = $pp.Value } + $restored[$field] = $ht + } + } + $tmp = Join-Path $env:TEMP "psp-wiz-$([System.IO.Path]::GetRandomFileName()).json" + try { + Save-WizardChoices -Choices $restored -UserSettingsPath $tmp + $w = Get-Content $tmp -Raw | ConvertFrom-Json + if ($w.defaults.opacity -ne 90) { throw "opacity dropped on resume (got '$($w.defaults.opacity)')" } + if ($w.defaults.cursorShape -ne 'bar') { throw 'cursorShape dropped on resume' } + if ($w.defaults.font.size -ne 12) { throw 'fontSize dropped on resume' } + if ($w.features.predictions -ne $true) { throw 'feature toggle dropped on resume' } + } + finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } +} + +# --- Install-WingetPackage (already-installed path) --- +T 'Install-WingetPackage' { + $r = Install-WingetPackage -Name 'PowerShell' -Id 'Microsoft.PowerShell' + if ($r -ne $true) { throw "expected true, got $r" } +} + +# --- Install-NerdFonts (font detection) --- +T 'Install-NerdFonts (detection)' { + $r = Install-NerdFonts + if ($r -ne $true) { throw "expected true, got $r" } +} + +# --- Install-OhMyPoshTheme (real download to temp) --- +$configCachePath = Join-Path $env:TEMP "psp-omp-$([System.IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory $configCachePath -Force | Out-Null +T 'Install-OhMyPoshTheme' { + $r = Install-OhMyPoshTheme -ThemeName 'testtheme' -ThemeUrl 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/agnoster.omp.json' + if ($r -ne $true) { throw "expected true, got $r" } + if (-not (Test-Path (Join-Path $configCachePath 'testtheme.omp.json'))) { throw 'theme file missing' } +} +Remove-Item $configCachePath -Recurse -Force -ErrorAction SilentlyContinue + +# --- EditorCandidates validation --- +T 'EditorCandidates' { + if (-not $EditorCandidates -or $EditorCandidates.Count -lt 5) { throw "only $($EditorCandidates.Count) candidates" } + foreach ($ed in $EditorCandidates) { + if (-not $ed.Cmd -or -not $ed.Display) { throw "invalid: $($ed | ConvertTo-Json -Compress)" } + } +} + +Write-Host '' +Write-Host " setup.ps1 functions: $ok ok, $fail fail" -ForegroundColor $(if ($fail -gt 0) { 'Red' } else { 'Green' }) +if ($fail -gt 0) { exit 1 } +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $fnOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -SetupSource $setupPath 2>&1 + foreach ($line in $fnOutput) { + $color = if ($line -match '^\s+OK') { 'Green' } elseif ($line -match '^\s+FAIL') { 'Red' } else { 'White' } + Write-Host " $line" -ForegroundColor $color + } + $fnFails = @($fnOutput | Where-Object { $_ -match '^\s+FAIL' }) + if ($LASTEXITCODE -ne 0 -or $fnFails.Count -gt 0) { throw "$($fnFails.Count) function(s) failed" } + Write-Result 'setup.ps1 functions sandbox' 'PASS' +} +catch { Write-Result 'setup.ps1 functions sandbox' 'FAIL' $_.Exception.Message } +finally { Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue } + +# ------------------------------------------------------- +# 24. setprofile.ps1 sandbox (copy to both PS dirs) +# ------------------------------------------------------- +Write-Host '[24/26] setprofile.ps1 sandbox' -ForegroundColor Cyan +try { + $sandboxScript = Join-Path $env:TEMP "setprofile-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($RepoRoot) +$ErrorActionPreference = 'Stop' + +$sb = Join-Path $env:TEMP "psp-setprofile-$([System.IO.Path]::GetRandomFileName())" +$srcDir = Join-Path $sb 'src' +$docsDir = Join-Path $sb 'Documents' +$ps7Dir = Join-Path $docsDir 'PowerShell' +$ps5Dir = Join-Path $docsDir 'WindowsPowerShell' +New-Item -ItemType Directory -Path $srcDir -Force | Out-Null + +Copy-Item (Join-Path $RepoRoot 'Microsoft.PowerShell_profile.ps1') $srcDir +Copy-Item (Join-Path $RepoRoot 'setprofile.ps1') $srcDir + +$origProfile = $PROFILE +$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' + +$errors = @() +try { + Push-Location $srcDir + & (Join-Path $srcDir 'setprofile.ps1') + Pop-Location + + foreach ($dir in @($ps7Dir, $ps5Dir)) { + $pf = Join-Path $dir 'Microsoft.PowerShell_profile.ps1' + if (-not (Test-Path $pf)) { $errors += "Not created: $pf" } + else { + $src = Get-Content (Join-Path $srcDir 'Microsoft.PowerShell_profile.ps1') -Raw + $dst = Get-Content $pf -Raw + if ($src -ne $dst) { $errors += "Content mismatch: $pf" } + } + } +} +finally { + $global:PROFILE = $origProfile + Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($errors) { + $errors | ForEach-Object { Write-Host "ASSERT: $_" -ForegroundColor Red } + exit 1 +} +Write-Host 'setprofile.ps1 sandbox passed' +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $spOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -RepoRoot $repoRoot 2>&1 + $assertLines = @($spOutput | Where-Object { $_ -match 'ASSERT:' }) + if ($assertLines) { $assertLines | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } } + if ($LASTEXITCODE -ne 0) { throw "setprofile sandbox failed" } + Write-Result 'setprofile.ps1 sandbox' 'PASS' +} +catch { Write-Result 'setprofile.ps1 sandbox' 'FAIL' $_.Exception.Message } +finally { Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue } + +# ------------------------------------------------------- +# 25. Install verification (tools, configs, fonts) +# ------------------------------------------------------- +Write-Host '[25/26] Install verification' -ForegroundColor Cyan +try { + $sandboxScript = Join-Path $env:TEMP "install-verify-$([System.IO.Path]::GetRandomFileName()).ps1" + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + $sandboxCode = @' +param($ProfileSource) +$ErrorActionPreference = 'Stop' + +# Refresh PATH from registry (catches winget/scoop installs missed by parent shell) +$env:PATH = [Environment]::GetEnvironmentVariable('Path', 'User') + ';' + [Environment]::GetEnvironmentVariable('Path', 'Machine') + +# Load profile to get $script:ProfileTools and tool-dependent function definitions +$env:CI = 'true' +. $ProfileSource + +$errors = @() +$warnings = @() + +# --- Tools: check each ProfileTools entry via Get-Command (warn-only, profile works without them) --- +$toolsFound = 0; $toolsMissing = 0 +foreach ($tool in $script:ProfileTools) { + $cmd = Get-Command $tool.Cmd -ErrorAction SilentlyContinue + if ($cmd) { + try { + $ver = & $tool.Cmd $tool.VerCmd 2>&1 | Out-String + Write-Host " OK $($tool.Name) = $($ver.Trim().Split([char]10)[0])" + $toolsFound++ + } + catch { $warnings += "$($tool.Name): found but --version failed"; $toolsMissing++ } + } + else { $warnings += "$($tool.Name): not installed"; $toolsMissing++ } +} + +# --- Required: cache directory and config files --- +$cachePath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' +if (-not (Test-Path $cachePath)) { $errors += "Cache dir missing: $cachePath" } +else { + foreach ($cf in @('theme.json', 'terminal-config.json')) { + $cfPath = Join-Path $cachePath $cf + if (-not (Test-Path $cfPath)) { $errors += "$cf not cached" } + else { + try { $null = Get-Content $cfPath -Raw | ConvertFrom-Json } + catch { $errors += "$cf corrupt: $_" } + } + } + $userSettings = Join-Path $cachePath 'user-settings.json' + if (-not (Test-Path $userSettings)) { $errors += 'user-settings.json not found' } +} + +# --- Required: profile_user.ps1 --- +$userProfile = Join-Path (Split-Path $PROFILE) 'profile_user.ps1' +if (-not (Test-Path $userProfile)) { $errors += 'profile_user.ps1 not found' } + +# --- OMP theme file in cache --- +$themeFiles = Get-ChildItem $cachePath -Filter '*.omp.json' -ErrorAction SilentlyContinue +if (-not $themeFiles) { $warnings += 'No OMP theme in cache (oh-my-posh may not be installed)' } +else { Write-Host " OK OMP theme: $($themeFiles[0].Name)" } + +# --- Nerd Font installed --- +try { + [void][System.Reflection.Assembly]::LoadWithPartialName("System.Drawing") + $fc = New-Object System.Drawing.Text.InstalledFontCollection + $nfMatch = @($fc.Families | Where-Object { $_.Name -match 'Caskaydia|NF|Nerd' }) + $fc.Dispose() + if ($nfMatch.Count -eq 0) { $warnings += 'No Nerd Font detected' } + else { Write-Host " OK Nerd Font: $($nfMatch[0].Name)" } +} +catch { $warnings += "Font check failed: $_" } + +# --- PSFzf module --- +if (Get-Module -ListAvailable -Name PSFzf) { Write-Host ' OK PSFzf module available' } +else { $warnings += 'PSFzf not installed' } + +# --- Windows Terminal settings (if WT installed) --- +$wtPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' +if (Test-Path $wtPath) { + try { + $wtRaw = Get-Content $wtPath -Raw + $_q = [char]34 + $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" + $wtRaw = $wtRaw -replace $jsoncPattern, '' + $wt = $wtRaw | ConvertFrom-Json + if ($wt.profiles.defaults.font.face) { Write-Host " OK WT font: $($wt.profiles.defaults.font.face)" } + else { $warnings += 'WT missing font.face in defaults' } + if ($wt.profiles.defaults.colorScheme) { Write-Host " OK WT colorScheme: $($wt.profiles.defaults.colorScheme)" } + else { $warnings += 'WT missing colorScheme in defaults' } + } + catch { $warnings += "WT parse failed: $_" } +} +else { Write-Host ' SKIP Windows Terminal not found' } + +# --- Tool strictness: fail if winget is available but zero tools found (skip in CI) --- +$hasWinget = [bool](Get-Command winget -ErrorAction SilentlyContinue) +if ($toolsFound -eq 0 -and $toolsMissing -gt 0 -and $hasWinget -and -not $env:GITHUB_ACTIONS) { + $errors += "All $toolsMissing managed tools missing (winget available - run setup.ps1 or Update-Profile to install)" +} + +# --- Summary --- +Write-Host "" +Write-Host " Tools: $toolsFound found, $toolsMissing missing (winget=$(if ($hasWinget) {'yes'} else {'no'}))" +if ($warnings) { + foreach ($w in $warnings) { Write-Host " WARN $w" -ForegroundColor Yellow } +} +if ($errors) { + foreach ($e in $errors) { Write-Host " FAIL $e" -ForegroundColor Red } + exit 1 +} +'@ + [System.IO.File]::WriteAllText($sandboxScript, $sandboxCode, $utf8NoBom) + $ivOutput = pwsh -NonInteractive -NoProfile -File $sandboxScript -ProfileSource $profilePath 2>&1 + foreach ($line in $ivOutput) { + $color = if ($line -match '^\s+OK') { 'Green' } elseif ($line -match '^\s+FAIL') { 'Red' } elseif ($line -match '^\s+WARN') { 'Yellow' } elseif ($line -match '^\s+SKIP') { 'DarkGray' } else { 'White' } + Write-Host " $line" -ForegroundColor $color + } + if ($LASTEXITCODE -ne 0) { throw 'Required verification checks failed (see FAIL above)' } + Write-Result 'Install verification' 'PASS' +} +catch { Write-Result 'Install verification' 'FAIL' $_.Exception.Message } +finally { Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue } + +# ------------------------------------------------------- +# 26. Command coverage audit (test.ps1 vs profile exports) +# ------------------------------------------------------- +Write-Host '[26/26] Command coverage audit' -ForegroundColor Cyan +try { + # Exported function names from profile source (AST) + $tokens = $null; $parseErrors = $null + $profileAst = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { throw "Profile parse errors: $($parseErrors.Count)" } + $allFns = $profileAst.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | + ForEach-Object Name | Sort-Object -Unique + + # Alias names from profile source + $profileRaw = Get-Content $profilePath -Raw + $aliasNames = [regex]::Matches($profileRaw, 'Set-Alias\s+-Name\s+([A-Za-z0-9\-]+)\s+-Value\s+([A-Za-z0-9\-]+)') | + ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique + + # Internal helper functions that are intentionally not direct user commands + $internalOnly = @( + 'Get-ExternalCommandPath' + 'Update-SessionPathFromRegistry' + 'Get-OhMyPoshInstallInfo' + 'Get-OhMyPoshMsiProductCode' + 'Get-OhMyPoshExecutablePath' + 'Get-ProfileToolExecutablePath' + 'Get-ProfileToolVersionText' + 'Test-WingetPackageInstalled' + 'Invoke-OhMyPoshCommand' + 'Get-OhMyPoshPromptContext' + 'Get-OhMyPoshPromptText' + 'Invoke-DownloadWithRetry' + 'Merge-JsonObject' + 'Invoke-WithTimeout' + 'Restart-TerminalToApply' + 'Clear-OhMyPoshCaches' + 'Write-JournalLine' + 'Invoke-PromptStage' + 'Invoke-ProfileHook' + 'Test-ProfileHistorySafeLine' + 'Save-TrustedDirectories' + 'Read-UserSettingsForWrite' + 'Get-WindowsTerminalSettingsPath' + 'Get-WindowsTerminalSettingsPaths' + 'Push-TabTitle' + 'Pop-TabTitle' + 'Resolve-WslUncPath' + 'Initialize-RestartManagerType' + 'ConvertTo-NativeArgumentLine' + 'Get-Utf8FileText' + 'Write-Utf8FileAtomic' + 'Set-PspFeatureOverride' + 'Get-LatestMainCommitSha' + ) + $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ } + + # Commands explicitly checked in step 17 existence list + $existNames = @() + $inExistBlock = $false + foreach ($line in (Get-Content $MyInvocation.MyCommand.Path)) { + if ($line.Contains('`$expected = @(')) { $inExistBlock = $true; continue } + if ($inExistBlock -and $line -match "^\s*\)") { break } + if ($inExistBlock) { + foreach ($m in [regex]::Matches($line, "'([^']+)'")) { + $existNames += $m.Groups[1].Value + } + } + } + + # Commands actually executed in step 22 (T 'name' { ... } with a scriptblock) + # vs commands skipped (T 'name' $null 'reason') + $execActual = @() + $execSkipped = @() + foreach ($line in (Get-Content $MyInvocation.MyCommand.Path)) { + $m = [regex]::Match($line, "^\s*T\s+'([^']+)'\s+(.+)") + if ($m.Success) { + $name = $m.Groups[1].Value + $rest = $m.Groups[2].Value.Trim() + if ($rest -match '^\$null\b' -or $rest -match "^'[^']+'\s*$") { + $execSkipped += $name + } else { + $execActual += $name + } + } + } + + $coveredNames = @($existNames + $execActual + $execSkipped) | Sort-Object -Unique + $testedNames = @($existNames + $execActual) | Sort-Object -Unique + $missingFns = $commandFns | Where-Object { $coveredNames -notcontains $_ } + $missingAliases = $aliasNames | Where-Object { $coveredNames -notcontains $_ } + + # Skip-only: commands that appear ONLY as skipped (not in existence check or actual execution) + $skipOnly = $execSkipped | Where-Object { $testedNames -notcontains $_ } + + # Allowed skip-only: commands that genuinely cannot be tested safely. + # Update-Profile/Update-PowerShell/Update-Tools/Invoke-ProfileWizard/Reconfigure-Profile + # are now probed via signature checks and safe early-exit paths. + $allowedSkipOnly = @( + 'reload' # reloads profile mid-test + 'Edit-Profile' # opens editor UI + 'ep' # opens editor UI (alias) + 'edit' # opens editor UI + 'Clear-ProfileCache' # destructive to real cache + 'Clear-Cache' # destructive to real cache + 'Uninstall-Profile' # tested in dedicated sandbox (steps 19/20/21) + 'gpush' # no remote configured + 'gpull' # no remote configured + 'gcl' # network + clones repo + 'lazyg' # no remote configured + 'g' # needs zoxide github dir + 'pkill' # destructive - kills processes + 'Stop-StuckProcess' # destructive - kills processes + 'Remove-LockedItem' # destructive - kills + deletes + 'Stop-ListeningPort' # interactive fzf picker + 'killports' # alias to Stop-ListeningPort + 'admin' # opens elevated terminal + 'su' # opens elevated terminal (alias) + 'flushdns' # requires admin + 'speedtest' # takes 30s+ download + 'wifipass' # requires admin/netsh + 'hosts' # opens elevated editor + 'vtscan' # needs API key + 'killport' # destructive - kills process + 'watch' # infinite loop + 'dlogs' # needs running container + 'dex' # needs running container + 'dstop' # destructive + 'dprune' # destructive + 'Copy-SshKey' # needs remote host + 'ssh-copy-key' # needs remote host (alias) + 'keygen' # writes keys to ~/.ssh + 'rdp' # opens RDP UI + 'icb' # PSReadLine handler only + 'Invoke-Clipboard' # PSReadLine handler only + 'ssh' # wraps ssh.exe; would open real TCP connect + 'wsl' # wraps wsl.exe; would launch distro shell + 'Enter-WslHere' # opens interactive WSL shell + 'wsl-here' # alias to Enter-WslHere + 'ConvertTo-WslPath' # requires WSL distro + 'ConvertTo-WindowsPath' # requires WSL distro + 'Stop-Wsl' # destructive: terminates distros + 'Get-WslIp' # requires running distro + 'Get-WslFile' # requires running distro + 'Show-WslTree' # requires running distro + 'wsl-tree' # alias to Show-WslTree + 'Open-WslExplorer' # opens Windows Explorer + 'wsl-explorer' # alias to Open-WslExplorer + 'htop' # launches TUI process viewer + 'mtr' # long traceroute+ping loop + 'nscan' # requires nmap binary + 'defscan' # triggers Defender scan + 'serve' # long-running HTTP server + 'gcof' # interactive fzf branch picker + 'mkvenv' # creates venv dir + activates + 'lint' # requires PSScriptAnalyzer (covered by lint job) + 'Start-ProfileTour' # interactive Read-Host walkthrough + 'Add-TrustedDirectory' # persists to user-settings.json (tested in sandbox) + 'Remove-TrustedDirectory' # persists to user-settings.json (tested in sandbox) + 'Set-TerminalBackground' # persists to user-settings.json + WT live (tested in sandbox) + ) + $unexpectedSkips = @($skipOnly | Where-Object { $allowedSkipOnly -notcontains $_ }) + + if ($missingFns -or $missingAliases) { + if ($missingFns) { + Write-Host " Missing functions in tests: $($missingFns -join ', ')" -ForegroundColor Red + } + if ($missingAliases) { + Write-Host " Missing aliases in tests: $($missingAliases -join ', ')" -ForegroundColor Red + } + throw "Coverage gaps found" + } + + if ($unexpectedSkips.Count -gt 0) { + Write-Host " Unexpected skip-only commands: $($unexpectedSkips -join ', ')" -ForegroundColor Red + Write-Host " These must be executed or added to allowedSkipOnly with justification" -ForegroundColor Red + throw "$($unexpectedSkips.Count) command(s) skipped without justification" + } + + $execPct = if ($coveredNames.Count -gt 0) { [math]::Round(($testedNames.Count / $coveredNames.Count) * 100) } else { 0 } + $detail = "functions=$($commandFns.Count), aliases=$($aliasNames.Count), executed=$($execActual.Count), skip-only=$($skipOnly.Count)/$($allowedSkipOnly.Count) allowed, exec%=$execPct" + if ($skipOnly.Count -gt 0) { + Write-Host " Allowed skip-only ($($skipOnly.Count)): $($skipOnly -join ', ')" -ForegroundColor DarkGray + } + Write-Result 'Command coverage audit' 'PASS' $detail +} +catch { Write-Result 'Command coverage audit' 'FAIL' $_.Exception.Message } + +# ===================================================================== +# Summary +# ===================================================================== +$stopwatch.Stop() +Write-Host '' +Write-Host '========================================================' -ForegroundColor Cyan +$total = $passed + $failed + $skipped +$color = if ($failed -gt 0) { 'Red' } elseif ($skipped -gt 0) { 'Yellow' } else { 'Green' } +Write-Host " $passed passed, $failed failed, $skipped skipped ($total total) in $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s" -ForegroundColor $color +Write-Host '========================================================' -ForegroundColor Cyan +Write-Host '' + +if ($failed -gt 0) { exit 1 } diff --git a/theme.json b/theme.json index 94a4e34..418d312 100644 --- a/theme.json +++ b/theme.json @@ -6,6 +6,13 @@ "windowsTerminal": { "colorScheme": "Tokyo Night", "cursorColor": "#a9b1d6", + "theme": "PSP.Default", + "themeDefinition": { + "name": "PSP.Default", + "tab": { "background": "#1a1b26", "unfocusedBackground": "#1a1b26" }, + "tabRow": { "background": "#1a1b26", "unfocusedBackground": "#1a1b26" }, + "window": { "applicationTheme": "dark" } + }, "scheme": { "name": "Tokyo Night", "background": "#1a1b26", @@ -29,5 +36,19 @@ "brightCyan": "#0db9d7", "brightWhite": "#acb0d0" } + }, + "psreadline": { + "colors": { + "Command": "#7aa2f7", + "Parameter": "#9ece6a", + "Operator": "#89ddff", + "Variable": "#e0af68", + "String": "#b9f27c", + "Number": "#ff9e64", + "Type": "#0db9d7", + "Comment": "#565f89", + "Keyword": "#bb9af7", + "Error": "#f7768e" + } } }