From cd1d6629ec57fa7f6abc7e60294487ade3460f72 Mon Sep 17 00:00:00 2001 From: Laurent Zogaj <143036376+26zl@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:13:50 +0200 Subject: [PATCH 1/6] feat: install wizard overhaul, hardening, and extensibility Wizard - 10 interactive steps covering all user-facing config: OMP theme, WT color scheme, Nerd Font, tab bar + window chrome, terminal appearance (opacity, useAcrylic, fontSize, cursorShape, padding, scrollbarState, historySize), PSReadLine colors (default / scheme-derived / skip), background image, editor preference, telemetry opt-out, feature toggles. - Quick-start preset skips all 10 steps with sensible defaults. - State schema versioning (WIZARD_STATE_SCHEMA) rejects stale state on -Resume. Security and hardening - SHA-256 hash input documented honestly: file integrity and reproducible installs, not MITM protection. Docs + in-code messages updated. - setup.ps1 hard-failure paths now exit 1 (was silent return) so CI and callers see non-zero on missing admin / internet / winget. - AllSigned execution policy downgrade now requires explicit consent. - Telemetry opt-out tracked via ownership marker so uninstall does not remove env vars the user set for other tools. - Install-NerdFonts returns false on timeout; wizard retries with default. WT multi-variant - New Get-WindowsTerminalSettingsPaths returns all installed WT variants so writers (profile Update-Profile phase 6, Set-TerminalBackground, setup step 10/10) iterate and update Stable + Preview + Canary + unpackaged. Update-Profile - Compares hash against both PS5 and PS7 installed profiles so a stale edition is caught even when the current $PROFILE is up to date. - Creates the current edition's profile directory on fresh install. - Triggers terminal restart on any runtime change (profile / theme / terminal / user-settings), not only when profile.ps1 itself changes. - -WhatIf trips the ShouldProcess gate before any network download. - Refreshes applied-commit baseline for the opt-in update-check. Invoke-ProfileWizard - SupportsShouldProcess, -ExpectedSha256, -SkipHashCheck, exit-code capture. - -WhatIf returns before Invoke-DownloadWithRetry. New commands - cdb [N] / cdh - directory history stack, bounded, stack-pop semantics (SuppressPwdHistoryPush flag avoids oscillation on repeat cdb). - duration - elapsed time of the last command. - Test-ProfileHealth / psp-doctor - diagnose tools, caches, fonts, PATH, modules. Cross-platform safe (WARN when System.Drawing unavailable). Automation - Native completer cache for kubectl / gh / docker (first load caches to disk, subsequent loads dot-source). - Tab-title indicators via PrePrompt hook: venv, conda, aws, aws-vault, k8s (read from ~/.kube/config, YAML quotes stripped), jobs count > 0. - Opt-in features.updateCheck (default false); weekly check against GitHub commits API, stamp only written on success. Wrappers and fallbacks - admin / su fall back to pwsh/powershell -Verb runAs when wt is missing. - setprofile.ps1 backs up existing profile to oldprofile.ps1 before overwrite. - setup.ps1 auto-detects a local clone, skips GitHub downloads. commandOverrides - Underscore-prefixed keys (e.g. _note, _comment) are skipped so documentation markers in example JSON never compile as scriptblocks. Trusted directories - Add-TrustedDirectory rejects unresolvable paths instead of silently persisting bad strings. - Remove-TrustedDirectory matches case-insensitively so users can clean stale entries after path casing changes. ConvertTo-Json -Depth - All WT settings writers and test roundtrips use -Depth 100 (was -Depth 10) so deeply nested action/command objects are not truncated to type names. Tests - Moved test harnesses to tests/ directory; root ci-functional.ps1 removed. - New probes for Update-Profile, Update-PowerShell, Update-Tools, Invoke-ProfileWizard, Reconfigure-Profile, cdb, cdh, duration, Test-ProfileHealth, psp-doctor. - Signature-contract checks catch parameter renames. - -WhatIf leak detection verifies ShouldProcess gates prevent temp-file creation. - tests/lint.ps1 pins PSScriptAnalyzer to 1.24.0 to match CI. - tests/locallab.ps1 path references updated post-reorg. Docs - README, CONTRIBUTING, SECURITY synced with new surface area. --- .github/workflows/ci.yml | 84 +- .gitignore | 6 +- CONTRIBUTING.md | 19 +- Microsoft.PowerShell_profile.ps1 | 3411 ++++++++++++++++-- README.md | 213 +- SECURITY.md | 13 +- setprofile.ps1 | 9 +- setup.ps1 | 901 ++++- ci-functional.ps1 => tests/ci-functional.ps1 | 331 +- tests/lint.ps1 | 19 + tests/locallab.ps1 | 306 ++ tests/rawhunt.ps1 | 1319 +++++++ tests/test.ps1 | 2065 +++++++++++ theme.json | 67 +- 14 files changed, 8432 insertions(+), 331 deletions(-) rename ci-functional.ps1 => tests/ci-functional.ps1 (65%) create mode 100644 tests/lint.ps1 create mode 100644 tests/locallab.ps1 create mode 100644 tests/rawhunt.ps1 create mode 100644 tests/test.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69e5535..8ad66c5 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" @@ -86,7 +87,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 +157,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 +191,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 +259,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 8 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 8) { Write-Host "FAIL: expected 8 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: | @@ -379,8 +445,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 +464,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..c12af4f 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -28,11 +28,10 @@ if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir - $_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) -} # 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 +45,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 +174,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 +183,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 { @@ -119,8 +208,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 +219,54 @@ function Get-ExternalCommandPath { return $null } +# 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( @@ -230,7 +367,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 +412,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 +683,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 +701,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 +735,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 +818,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 +833,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 +844,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 +856,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) { @@ -781,15 +966,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 +1094,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" @@ -960,6 +1168,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,7 +1232,9 @@ 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-Host "Windows Terminal settings updated." -ForegroundColor Green @@ -1098,8 +1323,32 @@ 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..." + try { + $_upOwner = ($repo_root -replace '^https?://(raw\.)?githubusercontent\.com/', '').Trim('/') + $_upApi = "https://api.github.com/repos/$_upOwner/$repo_name/commits/main" + $_upResp = Invoke-RestMethod -Uri $_upApi -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop + if ($_upResp -and $_upResp.sha) { + $_upBaseline = Join-Path $cacheDir 'applied-commit.sha' + [System.IO.File]::WriteAllText($_upBaseline, $_upResp.sha, [System.Text.UTF8Encoding]::new($false)) + } + } + 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 +1375,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 @@ -1288,10 +1537,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 @@ -1365,43 +1719,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 +1910,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 @@ -1999,7 +2416,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 +2425,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 $found = $true } catch { @@ -2056,7 +2473,7 @@ function vtscan { $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).data if (-not $uploadUrl) { Write-Error "VirusTotal did not return an upload URL."; return } Write-Host 'Using large-file upload endpoint.' -ForegroundColor DarkGray } @@ -2113,14 +2530,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 +2551,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,6 +2786,162 @@ function svc { # Reload profile in current session (useful after editing profile_user.ps1 or user-settings.json) function reload { . $PROFILE } +# 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() + + $results = @() + + # 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)' } + } + } + + # 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" } + } + 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" @@ -2179,20 +2950,103 @@ function Clear-ProfileCache { Write-Host "No cache directory found." -ForegroundColor Yellow return } - $items = Get-ChildItem $cacheDir -Exclude "user-settings.json" -ErrorAction SilentlyContinue + # 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) { - Remove-Item $item.FullName -Force -ErrorAction SilentlyContinue + 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 -ExpectedSha256 or explicitly +# confirm -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, + [switch]$SkipHashCheck + ) + + $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 " 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' } + + $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 $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 { @@ -2209,8 +3063,8 @@ function Uninstall-Profile { $preserved = @() # Phase 1: Windows Terminal settings - $wtSettingsPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' - if (Test-Path (Split-Path $wtSettingsPath)) { + $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 @@ -2419,6 +3273,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 +3285,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 +3337,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 +3394,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 +3406,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 +3420,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' } @@ -2609,8 +3482,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 +3493,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 +3526,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 +3618,252 @@ 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, 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 } + $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 +4005,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 } @@ -2940,7 +4099,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 +4130,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 +4158,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) { @@ -3058,176 +4223,1298 @@ 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 - } - catch { - $supportsPrediction = $false - } +# 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', $_) } +} - if ($supportsPrediction) { - try { - Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction Stop - Set-PSReadLineOption -PredictionViewStyle ListView -ErrorAction Stop - } - catch { - Write-Verbose "PSReadLine prediction unavailable: $_" - } - } - } - Set-PSReadLineOption -MaximumHistoryCount 10000 +# 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) } +} - # Custom key handlers - Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward - Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward - Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete - Set-PSReadLineKeyHandler -Chord 'Ctrl+d' -Function DeleteChar - Set-PSReadLineKeyHandler -Chord 'Ctrl+w' -Function BackwardDeleteWord - Set-PSReadLineKeyHandler -Chord 'Alt+d' -Function DeleteWord - Set-PSReadLineKeyHandler -Chord 'Ctrl+LeftArrow' -Function BackwardWord - Set-PSReadLineKeyHandler -Chord 'Ctrl+RightArrow' -Function ForwardWord - Set-PSReadLineKeyHandler -Chord 'Ctrl+z' -Function Undo - Set-PSReadLineKeyHandler -Chord 'Ctrl+y' -Function Redo - $smartPasteHandler = { - try { Invoke-Clipboard } - catch { [Microsoft.PowerShell.PSConsoleReadLine]::Ding() } - } - Set-PSReadLineKeyHandler -Chord 'Alt+v' -BriefDescription SmartPaste -Description 'Paste clipboard as one block into prompt' -ScriptBlock $smartPasteHandler +# 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', $_) } +} - # fzf integration via PSFzf (fuzzy history search on Ctrl+R, file finder on Ctrl+T) - if (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"' - } - if (-not $env:FZF_DEFAULT_OPTS) { - $env:FZF_DEFAULT_OPTS = '--height=40% --layout=reverse' - } - if (Get-Module -ListAvailable -Name PSFzf) { - Import-Module PSFzf -ErrorAction SilentlyContinue - if (Get-Module PSFzf) { - Set-PsFzfOption -PSReadlineChordProvider 'Ctrl+t' -PSReadlineChordReverseHistory 'Ctrl+r' - } - } - } +# 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) } +} - # 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) - } +# 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', $_) } } -# Custom completion for common commands -$scriptblock = { - param($wordToComplete, $commandAst, $cursorPosition) - $customCompletions = @{ - 'git' = @('status', 'add', 'commit', 'push', 'pull', 'clone', 'checkout') - 'npm' = @('install', 'start', 'run', 'test', 'build') - 'deno' = @('run', 'compile', 'test', 'lint', 'fmt', 'cache', 'info', 'doc', 'upgrade') - } +# 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) } +} - if (-not $commandAst.CommandElements -or $commandAst.CommandElements.Count -eq 0) { return } - $command = $commandAst.CommandElements[0].Value - if ($customCompletions.ContainsKey($command)) { - $customCompletions[$command] | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - } - } +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', $_) } } -Register-ArgumentCompleter -Native -CommandName git, npm, deno -ScriptBlock $scriptblock -# dotnet completion (only if dotnet is installed) -if (Get-Command dotnet -ErrorAction SilentlyContinue) { - $dotnetScriptblock = { - param($wordToComplete, $commandAst, $cursorPosition) - dotnet complete --position $cursorPosition $commandAst.ToString() | +# 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 { - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + $tip = "$($_.State), WSL$($_.Version)" + $(if ($_.Default) { ' (default)' } else { '' }) + [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $tip) } } - Register-ArgumentCompleter -Native -CommandName dotnet -ScriptBlock $dotnetScriptblock + catch { $null = $_ } } -# Oh My Posh initialization (interactive only; render with explicit --config every prompt) -if ($isInteractive) { - $ompExecutablePath = Get-OhMyPoshExecutablePath - if ($ompExecutablePath) { - # Read the selected theme from cached config; user-settings.json can override the theme name. - $profileConfigPath = Join-Path $cacheDir "theme.json" - $themeName = $null - if (Test-Path $profileConfigPath) { - try { - $cfg = Get-Content $profileConfigPath -Raw | ConvertFrom-Json - if ($cfg -and $cfg.theme -and $cfg.theme.name) { $themeName = $cfg.theme.name } - } - catch { Write-Verbose "Failed to parse theme.json: $_" } - } +# 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 = $_ } +} - $userSettingsStartup = Join-Path $cacheDir "user-settings.json" - if (Test-Path $userSettingsStartup) { - try { - $userCfg = Get-Content $userSettingsStartup -Raw | ConvertFrom-Json - if ($userCfg -and $userCfg.theme -and $userCfg.theme.name) { $themeName = $userCfg.theme.name } - } - catch { Write-Verbose "Failed to parse user-settings.json: $_" } - } +# 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) } +} - if (-not $themeName) { - Write-Warning "No cached OMP theme is configured. Run Update-Profile or setup.ps1 to restore theme.json." +# 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 } +} - $localThemePath = if ($themeName) { Join-Path $cacheDir "$themeName.omp.json" } else { $null } - if ($localThemePath -and -not (Test-Path $localThemePath)) { - # Only recover from local legacy paths at startup. We intentionally avoid network/theme downloads here. - $oldThemePath = Join-Path (Split-Path $PROFILE) "$themeName.omp.json" - if (Test-Path $oldThemePath) { - try { Move-Item $oldThemePath $localThemePath -Force -ErrorAction Stop } - catch { Write-Warning "Could not migrate theme from Documents: $_" } +# 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) } + } +} - if ($localThemePath -and (Test-Path $localThemePath)) { +# 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 { - $themeContent = Get-Content $localThemePath -Raw -ErrorAction Stop - if ([string]::IsNullOrWhiteSpace($themeContent)) { throw 'Theme file is empty' } - $null = $themeContent | ConvertFrom-Json + $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 { - Write-Warning "Configured OMP theme is invalid at '$localThemePath': $_" - $localThemePath = $null + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + $lost++ } } - - if ($localThemePath -and (Test-Path $localThemePath)) { + $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) + } + } +} + +# 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-Content $_userSettingsForRL -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + 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 } +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) + # 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 + } + + if ($supportsPrediction) { + try { + Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction Stop + Set-PSReadLineOption -PredictionViewStyle ListView -ErrorAction Stop + } + catch { + Write-Verbose "PSReadLine prediction unavailable: $_" + } + } + } + Set-PSReadLineOption -MaximumHistoryCount 10000 + + # Custom key handlers + Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward + Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward + Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete + Set-PSReadLineKeyHandler -Chord 'Ctrl+d' -Function DeleteChar + Set-PSReadLineKeyHandler -Chord 'Ctrl+w' -Function BackwardDeleteWord + Set-PSReadLineKeyHandler -Chord 'Alt+d' -Function DeleteWord + Set-PSReadLineKeyHandler -Chord 'Ctrl+LeftArrow' -Function BackwardWord + Set-PSReadLineKeyHandler -Chord 'Ctrl+RightArrow' -Function ForwardWord + Set-PSReadLineKeyHandler -Chord 'Ctrl+z' -Function Undo + Set-PSReadLineKeyHandler -Chord 'Ctrl+y' -Function Redo + $smartPasteHandler = { + try { Invoke-Clipboard } + catch { [Microsoft.PowerShell.PSConsoleReadLine]::Ding() } + } + 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) + # 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"' + } + if (-not $env:FZF_DEFAULT_OPTS) { + $env:FZF_DEFAULT_OPTS = '--height=40% --layout=reverse' + } + if (Get-Module -ListAvailable -Name PSFzf) { + Import-Module PSFzf -ErrorAction SilentlyContinue + if (Get-Module PSFzf) { + Set-PsFzfOption -PSReadlineChordProvider 'Ctrl+t' -PSReadlineChordReverseHistory 'Ctrl+r' + } + } + } + + # 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) + } + + # 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 = $_ } + } +} + +# Custom completion for common commands +$scriptblock = { + param($wordToComplete, $commandAst, $cursorPosition) + $customCompletions = @{ + 'git' = @('status', 'add', 'commit', 'push', 'pull', 'clone', 'checkout') + 'npm' = @('install', 'start', 'run', 'test', 'build') + 'deno' = @('run', 'compile', 'test', 'lint', 'fmt', 'cache', 'info', 'doc', 'upgrade') + } + + if (-not $commandAst.CommandElements -or $commandAst.CommandElements.Count -eq 0) { return } + $command = $commandAst.CommandElements[0].Value + if ($customCompletions.ContainsKey($command)) { + $customCompletions[$command] | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } +} +Register-ArgumentCompleter -Native -CommandName git, npm, deno -ScriptBlock $scriptblock + +# dotnet completion (only if dotnet is installed) +if (Get-Command dotnet -ErrorAction SilentlyContinue) { + $dotnetScriptblock = { + param($wordToComplete, $commandAst, $cursorPosition) + dotnet complete --position $cursorPosition $commandAst.ToString() | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + Register-ArgumentCompleter -Native -CommandName dotnet -ScriptBlock $dotnetScriptblock +} + +# Oh My Posh initialization (interactive only; render with explicit --config every prompt) +if ($isInteractive) { + $ompExecutablePath = Get-OhMyPoshExecutablePath + if ($ompExecutablePath) { + # Read the selected theme from cached config; user-settings.json can override the theme name. + $profileConfigPath = Join-Path $cacheDir "theme.json" + $themeName = $null + if (Test-Path $profileConfigPath) { + try { + $cfg = Get-Content $profileConfigPath -Raw | ConvertFrom-Json + if ($cfg -and $cfg.theme -and $cfg.theme.name) { $themeName = $cfg.theme.name } + } + catch { Write-Verbose "Failed to parse theme.json: $_" } + } + + $userSettingsStartup = Join-Path $cacheDir "user-settings.json" + if (Test-Path $userSettingsStartup) { + try { + $userCfg = Get-Content $userSettingsStartup -Raw | ConvertFrom-Json + if ($userCfg -and $userCfg.theme -and $userCfg.theme.name) { $themeName = $userCfg.theme.name } + } + catch { Write-Verbose "Failed to parse user-settings.json: $_" } + } + + if (-not $themeName) { + Write-Warning "No cached OMP theme is configured. Run Update-Profile or setup.ps1 to restore theme.json." + } + + $localThemePath = if ($themeName) { Join-Path $cacheDir "$themeName.omp.json" } else { $null } + if ($localThemePath -and -not (Test-Path $localThemePath)) { + # Only recover from local legacy paths at startup. We intentionally avoid network/theme downloads here. + $oldThemePath = Join-Path (Split-Path $PROFILE) "$themeName.omp.json" + if (Test-Path $oldThemePath) { + try { Move-Item $oldThemePath $localThemePath -Force -ErrorAction Stop } + catch { Write-Warning "Could not migrate theme from Documents: $_" } + } + } + + if ($localThemePath -and (Test-Path $localThemePath)) { + try { + $themeContent = Get-Content $localThemePath -Raw -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($themeContent)) { throw 'Theme file is empty' } + $null = $themeContent | ConvertFrom-Json + } + catch { + Write-Warning "Configured OMP theme is invalid at '$localThemePath': $_" + $localThemePath = $null + } + } + + if ($localThemePath -and (Test-Path $localThemePath)) { try { $null = Get-Content $localThemePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop @@ -3261,6 +5548,7 @@ if ($isInteractive) { $Function:prompt = { $originalSuccess = $? $originalLastExitCode = $global:LASTEXITCODE + Invoke-PromptStage try { $output = Get-OhMyPoshPromptText ` -Type 'primary' ` @@ -3376,6 +5664,307 @@ 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 - dot-sourced last; persistent overrides' + 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)" + } + } + } + + # 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 + } + foreach ($p in $bgProps) { + if ($settings.defaults.PSObject.Properties[$p]) { $settings.defaults.PSObject.Properties.Remove($p) } + } + if (-not $Clear) { + $settings.defaults | Add-Member -NotePropertyName 'backgroundImage' -NotePropertyValue $resolved -Force + $settings.defaults | Add-Member -NotePropertyName 'backgroundImageOpacity' -NotePropertyValue $Opacity -Force + $settings.defaults | Add-Member -NotePropertyName 'backgroundImageStretchMode' -NotePropertyValue $StretchMode -Force + $settings.defaults | Add-Member -NotePropertyName 'backgroundImageAlignment' -NotePropertyValue $Alignment -Force + } + if ($PSCmdlet.ShouldProcess($settingsPath, 'Persist terminal background in user-settings.json')) { + $json = $settings | ConvertTo-Json -Depth 10 + [System.IO.File]::WriteAllText($settingsPath, $json, [System.Text.UTF8Encoding]::new($false)) + } + + # 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-Content $wtSettingsPath -Raw) -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 } + foreach ($p in $bgProps) { + if ($wt.profiles.defaults.PSObject.Properties[$p]) { $wt.profiles.defaults.PSObject.Properties.Remove($p) } + } + if (-not $Clear) { + $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImage' -NotePropertyValue $resolved -Force + $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImageOpacity' -NotePropertyValue $Opacity -Force + $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImageStretchMode' -NotePropertyValue $StretchMode -Force + $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImageAlignment' -NotePropertyValue $Alignment -Force + } + if ($PSCmdlet.ShouldProcess($wtSettingsPath, 'Apply terminal background live')) { + $wtJson = $wt | ConvertTo-Json -Depth 100 + [System.IO.File]::WriteAllText($wtSettingsPath, $wtJson, [System.Text.UTF8Encoding]::new($false)) + } + } + 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-Content -LiteralPath $Path -Raw -ErrorAction Stop + 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 + [System.IO.File]::WriteAllText($settingsPath, $json, [System.Text.UTF8Encoding]::new($false)) + 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 +5988,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 +6009,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 +6039,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). @@ -3463,6 +6056,7 @@ ${g}vt${r} - Full VirusTotal CLI (vt-cli). Run ${g}vt --help${r} fo ${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 +6070,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,11 +6132,190 @@ ${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} - dot-sourced last; PS-level overrides. + ${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 quick scan' } + @{ 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 + # User overrides (survives Update-Profile) $userProfile = Join-Path (Split-Path $PROFILE) "profile_user.ps1" if (Test-Path $userProfile) { @@ -3500,9 +6323,141 @@ if (Test-Path $userProfile) { catch { Write-Warning "Failed to load profile_user.ps1: $_" } } +# Consume user-settings.json: feature toggles, command overrides, trusted directories. +# This runs AFTER profile_user.ps1 so explicit PS-level overrides still win over JSON commandOverrides. +$userSettingsPath = Join-Path $cacheDir 'user-settings.json' +$script:UserSettings = $null +if (Test-Path $userSettingsPath) { + try { + $_rawSettings = Get-Content $userSettingsPath -Raw -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($_rawSettings)) { + $script:UserSettings = $_rawSettings | ConvertFrom-Json -ErrorAction Stop + } + } + catch { Write-Warning "user-settings.json unreadable: $($_.Exception.Message)" } +} +if ($script:UserSettings) { + if ($script:UserSettings.PSObject.Properties['features']) { + foreach ($prop in $script:UserSettings.features.PSObject.Properties) { + if ($script:PSP.Features.ContainsKey($prop.Name)) { + # Handle string "false"/"0" -> $false explicitly. Bare [bool] coerces any non-empty string to $true. + $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 + } + } + } + } + if ($script:UserSettings.PSObject.Properties['trustedDirs']) { + foreach ($d in @($script:UserSettings.trustedDirs | Where-Object { $_ })) { + [void]$script:PSP.TrustedDirs.Add([string]$d) + } + } + 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 + } + } + } +} + +# 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) { + # $repo_root is "https://raw.githubusercontent.com/"; derive owner for the API URL. + $_ucOwner = ($repo_root -replace '^https?://(raw\.)?githubusercontent\.com/', '').Trim('/') + $_ucApi = "https://api.github.com/repos/$_ucOwner/$repo_name/commits/main" + $_ucLatest = $null + try { + $_ucResp = Invoke-RestMethod -Uri $_ucApi -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop + $_ucLatest = $_ucResp.sha + } + catch { $null = $_ } + 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..3f90ace 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,35 @@ # 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) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](#license) -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+. - -**Why use this?** - -- **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 - -Originally forked and inspired by [ChrisTitusTech/powershell-profile](https://github.com/ChrisTitusTech/powershell-profile). - -## Install - -Run in an **elevated** PowerShell window: +> A modern PowerShell profile for Windows. `irm | iex` gives you 130+ Unix-style commands, a tuned Oh My Posh prompt, fuzzy search, zoxide, and a `p10k configure`-style install wizard - in one window, with a full uninstall, self-update, and CI behind it. ```powershell irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" | iex ``` -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). +Run that in an **elevated** PowerShell window. 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). + +## At a glance + +| | | +| --- | --- | +| **130+ commands** | git, files, unix tools, network, security, developer, sysadmin, WSL, docker, ssh, clipboard | +| **Install wizard** | Pick OMP theme, WT color scheme (8 curated), Nerd Font (6 curated), tab-bar + window chrome, terminal appearance (opacity, font size, cursor shape, scrollbar, padding, history size, acrylic), PSReadLine colors (default/scheme-derived/skip), background, editor, telemetry opt-out, feature toggles. `-Resume` on interrupt. | +| **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 > **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. @@ -34,9 +39,10 @@ The terminal restarts automatically when setup finishes (new tab in Windows Term git clone https://github.com/26zl/PowerShellPerfect.git cd PowerShellPerfect .\setup.ps1 -.\setprofile.ps1 ``` +`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 (not available via `irm | iex`): ```powershell @@ -58,7 +64,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 @@ -75,12 +81,28 @@ Optional switches: `-RemoveTools` (winget-managed tools plus direct/MSI Oh My Po ## Customization -Two files survive updates and override everything: +Four extension points survive updates. From simplest to most powerful: + +- **`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 dot-sourced last: aliases, functions, editor, colors, modules +- **`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 + +Call `Start-ProfileTour` for a live walkthrough, or `Get-ProfileCommand -Category ` to list what's available. -- **`profile_user.ps1`** (`Split-Path $PROFILE`) - PowerShell overrides: aliases, functions, editor, colors, modules -- **`user-settings.json`** (`%LOCALAPPDATA%\PowerShellProfile\`) - Terminal overrides: theme, opacity, font, keybindings +### Background Image -Both are created automatically during setup. `profile_user.ps1` is dot-sourced last so your settings always win. +```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 +133,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 +166,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 +210,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 @@ -202,18 +242,60 @@ Run `Show-Help` in your terminal for a colored version of this list. | `urlencode` / `urldecode ` | URL encode / decode | | `vtscan ` | VirusTotal scan + open in browser | | `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 +312,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 +341,64 @@ 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) | + +## Install Wizard + +Inspired by [powerlevel10k](https://github.com/romkatv/powerlevel10k)'s `p10k configure`. Auto-runs on interactive installs; `setup.ps1 -SkipWizard` bypasses it (and CI/AI-agent environments always skip). + +```powershell +# Force-run the wizard during install: +.\setup.ps1 -Wizard + +# Skip and use repo defaults: +.\setup.ps1 -SkipWizard + +# Resume a half-finished wizard (state in %TEMP%\psp-wizard-state.json): +.\setup.ps1 -Resume + +# Re-run the wizard any time after install (downloads latest setup.ps1 + elevates): +Reconfigure-Profile +``` + +**Steps**: + +1. **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`. +2. **Color scheme** — curated 8-pack: Breaking Bad, Tokyo Night, Gruvbox Dark, Dracula, Catppuccin Mocha, Nord, One Half Dark, Solarized Dark. Full scheme definitions embedded; no extra network. +3. **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. +4. **Tab bar color** — presets: scheme-match (seamless), pure black, warm brown, custom hex, or skip. Applied via a custom WT theme definition. +5. **Background image** — optional path + opacity (0.05-0.50). Skipped by default. +6. **Feature toggles** — `psfzf`, `predictions`, `startupMessage`, `perDirProfiles`, `commandOverrides` — y/n per item with sensible defaults. + +**Design**: + +- 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. + +## 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. + +## 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). +- `psp doctor` command - runs `Test-Profile` + environment checks + auto-fixes common issues. +- 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..90e06e1 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`) +- `Update-Profile` and `Invoke-ProfileWizard` require SHA-256 hash input before executing a downloaded payload, or explicit `-SkipHashCheck`. **What this protects**: file integrity (truncated/corrupted downloads) and reproducible installs (the same `-ExpectedSha256` value 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 `Update-Profile -ExpectedSha256 `. - PSReadLine history filters out lines containing: `password`, `secret`, `token`, `api[_-]?key`, `connectionstring`, `credential`, `bearer` - 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..a86f075 100644 --- a/setprofile.ps1 +++ b/setprofile.ps1 @@ -15,7 +15,14 @@ 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 so an existing profile is preserved before overwrite. + if (Test-Path -Path $targetProfile -PathType Leaf) { + $backupPath = Join-Path $dir "oldprofile.ps1" + Copy-Item -Path $targetProfile -Destination $backupPath -Force + Write-Host " Backup saved to [$backupPath]" -ForegroundColor DarkGray + } + 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..87332cc 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -16,7 +16,14 @@ 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 ) # Normalize agent detection (same as profile): if host set a known agent var, set AI_AGENT so we only check one name @@ -26,22 +33,610 @@ if (-not [bool]$env:AI_AGENT -and ([bool]$env:AGENT_ID -or [bool]$env:CLAUDE_COD $RepoBase = "https://raw.githubusercontent.com/26zl/PowerShellPerfect/main" +# 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 +} + +# 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 = 'Breaking Bad'; Desc = 'Desert cream on warm brown + meth pink (matches van.jpg)' + Scheme = @{ name = 'Breaking Bad'; background = '#1a1410'; foreground = '#e8d9ba'; cursorColor = '#ff4d94'; selectionBackground = '#4a3c2e' + black = '#2a221d'; red = '#d2322d'; green = '#8ba446'; yellow = '#f2b548'; blue = '#5bb8e6'; purple = '#ff4d94'; cyan = '#7fb1a8'; white = '#d4c4a5' + brightBlack = '#5e5347'; brightRed = '#e74c3c'; brightGreen = '#a8c266'; brightYellow = '#f9cc6a'; brightBlue = '#7fc9ef'; brightPurple = '#ff6ba8'; brightCyan = '#9bcbc2'; brightWhite = '#f2e8d2' } } + @{ Name = 'Tokyo Night'; Desc = 'Cool blue-purple, balanced for long coding sessions' + 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, matches desert aesthetic' + 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 { + 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 + } + Write-Host ("Resuming from step after: {0}" -f ($choices.CompletedSteps -join ', ')) -ForegroundColor DarkGray + } + else { Remove-Item $StatePath -Force -ErrorAction SilentlyContinue } + } + } + catch { 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'); 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 'Breaking Bad' + 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 { '#1a1410' } + $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 +# Set execution policy so the profile can load on future sessions. +# AllSigned is STRICTER than RemoteSigned; changing it without consent is a real policy +# downgrade, so prompt explicitly. Restricted/Undefined are less permissive defaults where +# a silent upgrade to RemoteSigned is the intended outcome. $currentUserPolicy = Get-ExecutionPolicy -Scope CurrentUser -if ($currentUserPolicy -in @('Restricted', 'AllSigned', 'Undefined')) { +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')) { Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force Write-Host "Execution policy set to RemoteSigned for CurrentUser." -ForegroundColor Green } @@ -134,11 +729,14 @@ function Install-NerdFonts { } } - if ($copied -gt 0 -and $pending) { - Write-Host " Warning: font copy timed out, $(@($pending).Count) file(s) may not have installed." -ForegroundColor Yellow - } Remove-Item -Path $extractPath -Recurse -Force Remove-Item -Path $zipFilePath -Force + if ($copied -gt 0 -and $pending) { + # 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 + } Write-Host " ${FontDisplayName} installed." -ForegroundColor Green return $true } @@ -167,7 +765,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' @@ -186,7 +784,63 @@ function Invoke-DownloadWithRetry { } } -# Resolve oh-my-posh executable path (Get-Command or known install locations) +# Resolve the active Windows Terminal settings.json across install variants. +# DUPLICATED from Microsoft.PowerShell_profile.ps1's Get-WindowsTerminalSettingsPath. +# Keep these two copies in sync per CLAUDE.md "Structural Duplication" guidance. +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 +} + +# Return ALL existing WT settings.json across variants so step [10/10] writes to every +# installed variant (Stable + Preview + Canary + unpackaged). DUPLICATED from profile. +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]$CommandName + ) + + $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 +} + +# 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 +853,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 @@ -223,7 +876,7 @@ function Get-OhMyPoshExecutablePath { # Check for internet connectivity before proceeding (skip when using a local repo) if (-not $LocalRepo -and -not (Test-InternetConnection)) { - return + if ($PSCommandPath) { exit 1 } else { return } } # JSONC comment-stripping regex (built via variable to avoid PS5 parser bug with [^"] in strings) @@ -237,8 +890,77 @@ if (!(Test-Path -Path $configCachePath)) { New-Item -Path $configCachePath -ItemType "directory" -Force | Out-Null } +# 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 } + } + 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 "theme.json" + $configTmp = Join-Path $env:TEMP ("psp-theme-" + [System.IO.Path]::GetRandomFileName() + ".json") if ($LocalRepo) { Copy-Item (Join-Path $LocalRepo 'theme.json') $configTmp -Force -ErrorAction Stop } @@ -256,7 +978,7 @@ catch { # Download terminal-config.json (WT behavior settings: scrollbar, historySize, keybindings) $terminalConfig = $null try { - $terminalConfigTmp = Join-Path $env:TEMP "terminal-config.json" + $terminalConfigTmp = Join-Path $env:TEMP ("psp-terminal-" + [System.IO.Path]::GetRandomFileName() + ".json") if ($LocalRepo) { Copy-Item (Join-Path $LocalRepo 'terminal-config.json') $terminalConfigTmp -Force -ErrorAction Stop } @@ -383,7 +1105,7 @@ if (Test-Path $userSettingsPath) { 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 + if ($PSCommandPath) { exit 1 } else { return } } Write-Host "" @@ -408,7 +1130,7 @@ 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" + $tempDownload = Join-Path $env:TEMP ("psp-profile_download_" + (Split-Path $dir -Leaf) + "_" + [System.IO.Path]::GetRandomFileName() + ".ps1") if ($LocalRepo) { Copy-Item (Join-Path $LocalRepo 'Microsoft.PowerShell_profile.ps1') $tempDownload -Force } @@ -466,12 +1188,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,11 +1225,51 @@ else { Write-Host " User settings file already exists at [$userSettingsTemplate] (preserved)" -ForegroundColor DarkGray } -# Editor preference (interactive prompt writes $script:EditorPriority into profile_user.ps1) +# 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 ($canPromptEditor) { +if ($script:WizardEditor) { + $chosenEditor = $script:WizardEditor + Write-Host " Using wizard choice: $chosenEditor" -ForegroundColor DarkGray + $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) { + 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') + Write-Host " $($chosen.Display) installed." -ForegroundColor Green + } + else { + Write-Host " Could not install $($chosen.Display) via winget. Using Notepad." -ForegroundColor Yellow + $chosenEditor = 'notepad' + } + } + else { + Write-Host " winget not found. Using Notepad." -ForegroundColor Yellow + $chosenEditor = 'notepad' + } + } + 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 wizard) ---`r`n$editorLine`r`n" + } + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($userProfilePath, $content, $utf8NoBom) + } + } +} +elseif ($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 @@ -603,7 +1385,11 @@ if ($ompPath -and $ompPath -notlike ((Join-Path $env:LOCALAPPDATA 'Microsoft\Win 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,7 +1414,13 @@ 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 +} +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 @@ -666,10 +1458,14 @@ $batInstalled = Install-WingetPackage -Name "bat" -Id "sharkdp.bat" Write-Host "[9/10] ripgrep" -ForegroundColor Cyan $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 +1546,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,18 +1606,50 @@ 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 diff --git a/ci-functional.ps1 b/tests/ci-functional.ps1 similarity index 65% rename from ci-functional.ps1 rename to tests/ci-functional.ps1 index b7f4c5a..00630d6 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 @@ -246,7 +248,7 @@ Invoke-TestCase -Name 'Full install flow on host (setup.ps1)' -Code { $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.' + throw 'setup.ps1 requires an elevated (Administrator) shell when run locally. Run tests/ci-functional.ps1 from an elevated pwsh so the full install flow can be validated.' } $setupPath = Join-Path $repoRoot 'setup.ps1' @@ -452,21 +454,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 +511,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', '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 +652,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 +753,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 @@ -698,6 +805,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 +845,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 +876,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) { @@ -793,6 +1098,16 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { 'Invoke-WithTimeout' 'Restart-TerminalToApply' 'Clear-OhMyPoshCaches' + 'Write-JournalLine' + 'Invoke-PromptStage' + 'Invoke-ProfileHook' + 'Save-TrustedDirectories' + 'Read-UserSettingsForWrite' + 'Get-WindowsTerminalSettingsPath' + 'Push-TabTitle' + 'Pop-TabTitle' + 'Resolve-WslUncPath' + 'Initialize-RestartManagerType' ) $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..dde6f03 --- /dev/null +++ b/tests/test.ps1 @@ -0,0 +1,2065 @@ +# 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 +try { + $env:CI = 'true' + pwsh -NonInteractive -NoProfile -Command ". '$profilePath'" + if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } + 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 "'", "''" + powershell.exe -NoProfile -Command "`$env:CI = 'true'; . '$escaped'" + if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } + 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' + ) + $missingFns = @() + foreach ($fn in $requiredFunctions) { + if ($setupContent -notmatch "function\s+$fn\b") { $missingFns += $fn } + } + 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)) +} + +# 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)' } + + # 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' } + + # 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', '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 '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 + uploads file' +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' } +} + +# --- 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' + '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' + 'Save-TrustedDirectories' + 'Read-UserSettingsForWrite' + 'Get-WindowsTerminalSettingsPath' + 'Push-TabTitle' + 'Pop-TabTitle' + 'Resolve-WslUncPath' + 'Initialize-RestartManagerType' + ) + $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..013bc5a 100644 --- a/theme.json +++ b/theme.json @@ -4,30 +4,51 @@ "url": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/pure.omp.json" }, "windowsTerminal": { - "colorScheme": "Tokyo Night", - "cursorColor": "#a9b1d6", + "colorScheme": "Breaking Bad", + "cursorColor": "#ff4d94", + "theme": "PSP.Default", + "themeDefinition": { + "name": "PSP.Default", + "tab": { "background": "#3a5062", "unfocusedBackground": "#3a5062" }, + "tabRow": { "background": "#3a5062", "unfocusedBackground": "#3a5062" }, + "window": { "applicationTheme": "dark" } + }, "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": "Breaking Bad", + "background": "#1a1410", + "foreground": "#e8d9ba", + "cursorColor": "#ff4d94", + "selectionBackground": "#4a3c2e", + "black": "#2a221d", + "red": "#d2322d", + "green": "#8ba446", + "yellow": "#f2b548", + "blue": "#5bb8e6", + "purple": "#ff4d94", + "cyan": "#7fb1a8", + "white": "#d4c4a5", + "brightBlack": "#5e5347", + "brightRed": "#e74c3c", + "brightGreen": "#a8c266", + "brightYellow": "#f9cc6a", + "brightBlue": "#7fc9ef", + "brightPurple": "#ff6ba8", + "brightCyan": "#9bcbc2", + "brightWhite": "#f2e8d2" + } + }, + "psreadline": { + "colors": { + "Command": "#f2b548", + "Parameter": "#8ba446", + "Operator": "#5bb8e6", + "Variable": "#f9cc6a", + "String": "#a8c266", + "Number": "#ff4d94", + "Type": "#e67e22", + "Comment": "#7a6955", + "Keyword": "#ff6ba8", + "Error": "#d2322d" } } } From fbf7266e02fad6d69935717685aef3f7e40fc883 Mon Sep 17 00:00:00 2001 From: Laurent Zogaj <143036376+26zl@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:14:02 +0200 Subject: [PATCH 2/6] chore: add .gitattributes with EOL normalization rules Enforce CRLF for .ps1 / .psm1 / .psd1 so Windows tooling sees the line endings it expects, LF for .json / .md / .yml / config, and binary mode for common asset types. Excludes tests/ from Linguist stats. --- .gitattributes | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .gitattributes 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 From 2af42a08c843c8026bbc80bbc77add80b70f85bc Mon Sep 17 00:00:00 2001 From: Laurent Zogaj <143036376+26zl@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:37:18 +0200 Subject: [PATCH 3/6] Changes and some fixes. --- .github/workflows/ci.yml | 15 +++--- Microsoft.PowerShell_profile.ps1 | 7 +-- README.md | 93 +++++++++++++++++--------------- setup.ps1 | 15 +++--- theme.json | 70 ++++++++++++------------ 5 files changed, 105 insertions(+), 95 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ad66c5..9c4c065 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,10 +283,10 @@ jobs: } $errors = 0 - # Schemes: expect 8 entries, each with Name, Desc, and a nested Scheme with name + background + # 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 8) { Write-Host "FAIL: expected 8 curated schemes, got $($schemes.Count)" -ForegroundColor Red; $errors++ } + 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) { @@ -345,7 +345,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]) { @@ -386,11 +387,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) diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index c12af4f..9e0be37 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -274,7 +274,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]) { @@ -3470,10 +3471,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 @('/c', $editorPath, $hostsPath) } else { - Start-Process $editorPath $hostsPath -Verb RunAs + Start-Process -FilePath $editorPath -ArgumentList @($hostsPath) -Verb RunAs } } diff --git a/README.md b/README.md index 3f90ace..c02011f 100644 --- a/README.md +++ b/README.md @@ -3,22 +3,62 @@ [![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)](#) [![License](https://img.shields.io/badge/license-MIT-green.svg)](#license) -> A modern PowerShell profile for Windows. `irm | iex` gives you 130+ Unix-style commands, a tuned Oh My Posh prompt, fuzzy search, zoxide, and a `p10k configure`-style install wizard - in one window, with a full uninstall, self-update, and CI behind it. +> ⚠️ **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. `irm | iex` 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. ```powershell irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" | iex ``` -Run that in an **elevated** PowerShell window. 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). +Run that in an **elevated** PowerShell window. 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). + +## 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 +# Default flow — wizard runs as part of installation +irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" | iex + +# 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 +``` + +**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**: + +- 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. ## At a glance | | | | --- | --- | | **130+ commands** | git, files, unix tools, network, security, developer, sysadmin, WSL, docker, ssh, clipboard | -| **Install wizard** | Pick OMP theme, WT color scheme (8 curated), Nerd Font (6 curated), tab-bar + window chrome, terminal appearance (opacity, font size, cursor shape, scrollbar, padding, history size, acrylic), PSReadLine colors (default/scheme-derived/skip), background, editor, telemetry opt-out, feature toggles. `-Resume` on interrupt. | +| **Install wizard (default)** | Runs automatically on `irm \| iex`. 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 | @@ -29,16 +69,19 @@ Run that in an **elevated** PowerShell window. The terminal restarts when setup 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 +## Install (alternatives) -> **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. +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. -### Manual Setup +> **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 +.\setup.ps1 # wizard runs by default +.\setup.ps1 -SkipWizard # apply repo defaults instead ``` `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. @@ -46,7 +89,7 @@ cd PowerShellPerfect When running locally you can override terminal defaults (not available via `irm | iex`): ```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: @@ -342,40 +385,6 @@ Tab-complete works on `-Distro` for all of these via live `Get-WslDistro` lookup | `pst` | Paste from clipboard | | `icb` | Insert clipboard into prompt (never executes) | -## Install Wizard - -Inspired by [powerlevel10k](https://github.com/romkatv/powerlevel10k)'s `p10k configure`. Auto-runs on interactive installs; `setup.ps1 -SkipWizard` bypasses it (and CI/AI-agent environments always skip). - -```powershell -# Force-run the wizard during install: -.\setup.ps1 -Wizard - -# Skip and use repo defaults: -.\setup.ps1 -SkipWizard - -# Resume a half-finished wizard (state in %TEMP%\psp-wizard-state.json): -.\setup.ps1 -Resume - -# Re-run the wizard any time after install (downloads latest setup.ps1 + elevates): -Reconfigure-Profile -``` - -**Steps**: - -1. **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`. -2. **Color scheme** — curated 8-pack: Breaking Bad, Tokyo Night, Gruvbox Dark, Dracula, Catppuccin Mocha, Nord, One Half Dark, Solarized Dark. Full scheme definitions embedded; no extra network. -3. **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. -4. **Tab bar color** — presets: scheme-match (seamless), pure black, warm brown, custom hex, or skip. Applied via a custom WT theme definition. -5. **Background image** — optional path + opacity (0.05-0.50). Skipped by default. -6. **Feature toggles** — `psfzf`, `predictions`, `startupMessage`, `perDirProfiles`, `commandOverrides` — y/n per item with sensible defaults. - -**Design**: - -- 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. - ## Tests Everything in `tests/` is tracked and runs locally in seconds. diff --git a/setup.ps1 b/setup.ps1 index 87332cc..18d5d8c 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -49,15 +49,11 @@ if ([string]::IsNullOrWhiteSpace($LocalRepo) -and $PSScriptRoot -and # 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 = 'Breaking Bad'; Desc = 'Desert cream on warm brown + meth pink (matches van.jpg)' - Scheme = @{ name = 'Breaking Bad'; background = '#1a1410'; foreground = '#e8d9ba'; cursorColor = '#ff4d94'; selectionBackground = '#4a3c2e' - black = '#2a221d'; red = '#d2322d'; green = '#8ba446'; yellow = '#f2b548'; blue = '#5bb8e6'; purple = '#ff4d94'; cyan = '#7fb1a8'; white = '#d4c4a5' - brightBlack = '#5e5347'; brightRed = '#e74c3c'; brightGreen = '#a8c266'; brightYellow = '#f9cc6a'; brightBlue = '#7fc9ef'; brightPurple = '#ff6ba8'; brightCyan = '#9bcbc2'; brightWhite = '#f2e8d2' } } - @{ Name = 'Tokyo Night'; Desc = 'Cool blue-purple, balanced for long coding sessions' + @{ 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, matches desert aesthetic' + @{ 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' } } @@ -384,7 +380,7 @@ function Start-InstallWizard { # STEP 2: color scheme if ('Scheme' -notin $choices.CompletedSteps) { - $pick = Select-WizardItem -Title 'Windows Terminal color scheme' -Items $script:CuratedSchemes -DefaultName 'Breaking Bad' + $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 @@ -403,7 +399,7 @@ function Start-InstallWizard { 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 { '#1a1410' } + $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' } @@ -995,7 +991,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]) { diff --git a/theme.json b/theme.json index 013bc5a..418d312 100644 --- a/theme.json +++ b/theme.json @@ -4,51 +4,51 @@ "url": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/pure.omp.json" }, "windowsTerminal": { - "colorScheme": "Breaking Bad", - "cursorColor": "#ff4d94", + "colorScheme": "Tokyo Night", + "cursorColor": "#a9b1d6", "theme": "PSP.Default", "themeDefinition": { "name": "PSP.Default", - "tab": { "background": "#3a5062", "unfocusedBackground": "#3a5062" }, - "tabRow": { "background": "#3a5062", "unfocusedBackground": "#3a5062" }, + "tab": { "background": "#1a1b26", "unfocusedBackground": "#1a1b26" }, + "tabRow": { "background": "#1a1b26", "unfocusedBackground": "#1a1b26" }, "window": { "applicationTheme": "dark" } }, "scheme": { - "name": "Breaking Bad", - "background": "#1a1410", - "foreground": "#e8d9ba", - "cursorColor": "#ff4d94", - "selectionBackground": "#4a3c2e", - "black": "#2a221d", - "red": "#d2322d", - "green": "#8ba446", - "yellow": "#f2b548", - "blue": "#5bb8e6", - "purple": "#ff4d94", - "cyan": "#7fb1a8", - "white": "#d4c4a5", - "brightBlack": "#5e5347", - "brightRed": "#e74c3c", - "brightGreen": "#a8c266", - "brightYellow": "#f9cc6a", - "brightBlue": "#7fc9ef", - "brightPurple": "#ff6ba8", - "brightCyan": "#9bcbc2", - "brightWhite": "#f2e8d2" + "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" } }, "psreadline": { "colors": { - "Command": "#f2b548", - "Parameter": "#8ba446", - "Operator": "#5bb8e6", - "Variable": "#f9cc6a", - "String": "#a8c266", - "Number": "#ff4d94", - "Type": "#e67e22", - "Comment": "#7a6955", - "Keyword": "#ff6ba8", - "Error": "#d2322d" + "Command": "#7aa2f7", + "Parameter": "#9ece6a", + "Operator": "#89ddff", + "Variable": "#e0af68", + "String": "#b9f27c", + "Number": "#ff9e64", + "Type": "#0db9d7", + "Comment": "#565f89", + "Keyword": "#bb9af7", + "Error": "#f7768e" } } } From 8b7053fc42d2b8cc05bd4655da5ca50ff50f86b9 Mon Sep 17 00:00:00 2001 From: Laurent Zogaj <143036376+26zl@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:00:20 +0200 Subject: [PATCH 4/6] Fixed some issues --- Microsoft.PowerShell_profile.ps1 | 19 +- setprofile.ps1 | 11 +- setup.ps1 | 356 +++++++++++++++++++++---------- tests/ci-functional.ps1 | 78 ++++--- 4 files changed, 314 insertions(+), 150 deletions(-) diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index 9e0be37..3162679 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -1518,6 +1518,20 @@ function Clear-Cache { ) if ($IncludeSystemCaches) { + # 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 + } + } $targets += @( @{ Name = "Windows Temp"; Path = "$env:SystemRoot\Temp\*"; Recurse = $true }, @{ Name = "Windows Prefetch"; Path = "$env:SystemRoot\Prefetch\*"; Recurse = $false } @@ -2384,7 +2398,10 @@ function genpass { $password = $result.ToString() Set-Clipboard $password Write-Host "Password copied to clipboard." -ForegroundColor Green - return $password + # 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. The clipboard is the sole delivery channel. + return } # Base64 encode/decode diff --git a/setprofile.ps1 b/setprofile.ps1 index a86f075..d0336f9 100644 --- a/setprofile.ps1 +++ b/setprofile.ps1 @@ -16,11 +16,18 @@ foreach ($dir in $profileDirs) { New-Item -Path $dir -ItemType "directory" -Force | Out-Null } $targetProfile = Join-Path $dir "Microsoft.PowerShell_profile.ps1" - # Mirror setup.ps1 backup behaviour so an existing profile is preserved before overwrite. + # Mirror setup.ps1 backup behaviour: timestamped + rolling 5 so repeated runs + # never clobber the original profile backup. if (Test-Path -Path $targetProfile -PathType Leaf) { - $backupPath = Join-Path $dir "oldprofile.ps1" + $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 diff --git a/setup.ps1 b/setup.ps1 index 18d5d8c..28feace 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -23,7 +23,10 @@ param( # -Resume continues from a prior incomplete wizard run (state in $env:TEMP\psp-wizard-state.json). [switch]$Wizard, [switch]$SkipWizard, - [switch]$Resume + [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 @@ -32,6 +35,10 @@ if (-not [bool]$env:AI_AGENT -and ([bool]$env:AGENT_ID -or [bool]$env:CLAUDE_COD } $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 @@ -45,6 +52,90 @@ if ([string]::IsNullOrWhiteSpace($LocalRepo) -and $PSScriptRoot -and 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() } +} + +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. @@ -304,6 +395,25 @@ function Start-InstallWizard { 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) { @@ -315,12 +425,15 @@ function Start-InstallWizard { else { Remove-Item $StatePath -Force -ErrorAction SilentlyContinue } } } - catch { 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'); Choices = $choices } + $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)) } @@ -609,10 +722,8 @@ 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. -# AllSigned is STRICTER than RemoteSigned; changing it without consent is a real policy -# downgrade, so prompt explicitly. Restricted/Undefined are less permissive defaults where -# a silent upgrade to RemoteSigned is the intended outcome. +# 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 -eq 'AllSigned') { $canPromptPolicy = [Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT @@ -633,29 +744,17 @@ if ($currentUserPolicy -eq 'AllSigned') { } } elseif ($currentUserPolicy -in @('Restricted', 'Undefined')) { - Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force - Write-Host "Execution policy set to RemoteSigned for CurrentUser." -ForegroundColor Green + 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 } } @@ -957,12 +1056,7 @@ if ($wantWizard) { try { $configTmp = Join-Path $env:TEMP ("psp-theme-" + [System.IO.Path]::GetRandomFileName() + ".json") - if ($LocalRepo) { - Copy-Item (Join-Path $LocalRepo 'theme.json') $configTmp -Force -ErrorAction Stop - } - else { - Invoke-DownloadWithRetry -Uri "$RepoBase/theme.json" -OutFile $configTmp - } + 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 @@ -975,12 +1069,7 @@ catch { $terminalConfig = $null try { $terminalConfigTmp = Join-Path $env:TEMP ("psp-terminal-" + [System.IO.Path]::GetRandomFileName() + ".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 - } + 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 @@ -1112,7 +1201,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 = @( @@ -1128,16 +1216,20 @@ foreach ($dir in $profileDirs) { } # Copy/download to temp first so a partial/corrupt download never overwrites the existing profile $tempDownload = Join-Path $env:TEMP ("psp-profile_download_" + (Split-Path $dir -Leaf) + "_" + [System.IO.Path]::GetRandomFileName() + ".ps1") - if ($LocalRepo) { - Copy-Item (Join-Path $LocalRepo 'Microsoft.PowerShell_profile.ps1') $tempDownload -Force - } - else { - Invoke-DownloadWithRetry -Uri $profileUrl -OutFile $tempDownload -TimeoutSec 30 - } + 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 @@ -1222,88 +1314,82 @@ else { Write-Host " User settings file already exists at [$userSettingsTemplate] (preserved)" -ForegroundColor DarkGray } -# 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 = $script:WizardEditor - Write-Host " Using wizard choice: $chosenEditor" -ForegroundColor DarkGray - $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) { - 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') - Write-Host " $($chosen.Display) installed." -ForegroundColor Green - } - else { - Write-Host " Could not install $($chosen.Display) via winget. Using Notepad." -ForegroundColor Yellow - $chosenEditor = 'notepad' - } - } - else { - Write-Host " winget not found. Using Notepad." -ForegroundColor Yellow - $chosenEditor = 'notepad' - } - } - Write-Host " Editor set to: $chosenEditor" -ForegroundColor Green - $editorLine = '$script:EditorPriority = @(' + "'$chosenEditor', 'notepad'" + ')' +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 (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 wizard) ---`r`n$editorLine`r`n" - } - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($userProfilePath, $content, $utf8NoBom) + 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) } } -elseif ($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) { + +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. + $machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + $userPath = [System.Environment]::GetEnvironmentVariable('PATH', 'User') + $env:PATH = (@($machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' 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 @@ -1319,6 +1405,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 { @@ -1379,6 +1468,10 @@ 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" } @@ -1415,25 +1508,50 @@ 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 @@ -1449,11 +1567,23 @@ 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). # Iterates ALL installed WT variants so Stable + Preview + Canary all receive the merge. diff --git a/tests/ci-functional.ps1 b/tests/ci-functional.ps1 index 00630d6..76cebff 100644 --- a/tests/ci-functional.ps1 +++ b/tests/ci-functional.ps1 @@ -235,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 tests/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 { From 3195f66d24b7faae55bdd94a784389ae0442c509 Mon Sep 17 00:00:00 2001 From: Laurent Zogaj <143036376+26zl@users.noreply.github.com> Date: Tue, 26 May 2026 00:47:34 +0200 Subject: [PATCH 5/6] Fixed issues --- .github/workflows/ci.yml | 2 +- Microsoft.PowerShell_profile.ps1 | 81 ++++++++++++++++--- README.md | 18 +++-- SECURITY.md | 4 +- setup.ps1 | 135 +++++++++++++++++++------------ tests/ci-functional.ps1 | 12 ++- tests/test.ps1 | 27 ++++++- 7 files changed, 201 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c4c065..39c04fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,7 +328,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") { diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index 3162679..e8181ca 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -219,6 +219,12 @@ 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). @@ -312,7 +318,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 @@ -1269,7 +1275,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)) { @@ -1445,7 +1451,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) { @@ -2419,7 +2425,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 @@ -2487,6 +2497,14 @@ 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) { @@ -2994,9 +3012,10 @@ function Clear-ProfileCache { # 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 -ExpectedSha256 or explicitly -# confirm -SkipHashCheck. Exit code of the child process is captured so we do not claim -# "Wizard complete" on a failure. +# 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( @@ -3004,9 +3023,16 @@ function Invoke-ProfileWizard { [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())) @@ -3032,12 +3058,23 @@ function Invoke-ProfileWizard { 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 @@ -5254,6 +5291,28 @@ function psgrep { } } +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' + '(?i)^\s*pwnd\s+' + '(?i)^\s*jwtd\s+' + '\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\b' + ) + foreach ($pattern in $sensitivePatterns) { + if ($Line -match $pattern) { return $false } + } + return $true +} + # 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). @@ -5379,9 +5438,7 @@ 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 @@ -6069,7 +6126,7 @@ ${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} @@ -6274,7 +6331,7 @@ $script:_seedCommands = @( @{ 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 quick scan' } + @{ 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' } diff --git a/README.md b/README.md index c02011f..7929a2d 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,23 @@ [![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. `irm | iex` 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 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. ```powershell -irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" | iex +$setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" +& ([scriptblock]::Create($setup)) -SkipHashCheck ``` -Run that in an **elevated** PowerShell window. 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). +Run that in an **elevated** PowerShell window. `-SkipHashCheck` is the explicit trust-on-download path; omit it to print the install-bundle SHA256 and stop before making changes, then re-run with `-ExpectedSha256 ''` after verifying what you want to pin. 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). ## 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 -# Default flow — wizard runs as part of installation -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 @@ -58,7 +60,7 @@ Reconfigure-Profile | | | | --- | --- | | **130+ commands** | git, files, unix tools, network, security, developer, sysadmin, WSL, docker, ssh, clipboard | -| **Install wizard (default)** | Runs automatically on `irm \| iex`. 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. | +| **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 | @@ -86,7 +88,7 @@ cd PowerShellPerfect `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 (not available via `irm | iex`): +When running locally you can override terminal defaults: ```powershell .\setup.ps1 -SkipWizard -Opacity 85 -ColorScheme "One Half Dark" -FontSize 12 @@ -283,7 +285,7 @@ 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) | diff --git a/SECURITY.md b/SECURITY.md index 90e06e1..60c2a33 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,8 +27,8 @@ The following areas are in scope for security reports: ## Security Measures -- `Update-Profile` and `Invoke-ProfileWizard` require SHA-256 hash input before executing a downloaded payload, or explicit `-SkipHashCheck`. **What this protects**: file integrity (truncated/corrupted downloads) and reproducible installs (the same `-ExpectedSha256` value 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 `Update-Profile -ExpectedSha256 `. -- 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 diff --git a/setup.ps1 b/setup.ps1 index 28feace..e5a8112 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -63,6 +63,59 @@ function Get-CombinedSha256 { 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 } @@ -722,6 +775,19 @@ 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 } +# 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 @@ -778,6 +844,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 @@ -786,16 +853,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" } @@ -824,8 +887,8 @@ function Install-NerdFonts { } } - Remove-Item -Path $extractPath -Recurse -Force - Remove-Item -Path $zipFilePath -Force + Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' + $tempRoot = $null if ($copied -gt 0 -and $pending) { # Partial install: some files never appeared under %SystemRoot%\Fonts within the # timeout. Report failure so callers can surface it instead of claiming success. @@ -841,44 +904,12 @@ 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 { - 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 $_ - } - } - } -} - # Resolve the active Windows Terminal settings.json across install variants. # DUPLICATED from Microsoft.PowerShell_profile.ps1's Get-WindowsTerminalSettingsPath. # Keep these two copies in sync per CLAUDE.md "Structural Duplication" guidance. @@ -935,6 +966,12 @@ 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 ';' +} + # Resolve oh-my-posh executable path (Get-ExternalCommandPath or known install locations) function Get-OhMyPoshExecutablePath { $candidatePaths = @( @@ -960,7 +997,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 @@ -969,8 +1006,8 @@ function Get-OhMyPoshExecutablePath { return $null } -# Check for internet connectivity before proceeding (skip when using a local repo) -if (-not $LocalRepo -and -not (Test-InternetConnection)) { +# 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 } } @@ -1355,9 +1392,7 @@ function Resolve-ConfiguredEditor { # 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. - $machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') - $userPath = [System.Environment]::GetEnvironmentVariable('PATH', 'User') - $env:PATH = (@($machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' + Update-SessionPathFromRegistry Write-Host " $($chosen.Display) installed." -ForegroundColor Green } else { diff --git a/tests/ci-functional.ps1 b/tests/ci-functional.ps1 index 76cebff..4b5a367 100644 --- a/tests/ci-functional.ps1 +++ b/tests/ci-functional.ps1 @@ -533,7 +533,7 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { 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', 'SkipHashCheck', 'WhatIf')) { + foreach ($p in @('Resume', 'NoElevate', 'ExpectedSha256', 'BundleExpectedSha256', 'SkipHashCheck', 'WhatIf')) { if (-not $cmd.Parameters.ContainsKey($p)) { throw "Invoke-ProfileWizard missing expected parameter: $p" } } } @@ -792,6 +792,11 @@ 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' } + if (-not (Test-ProfileHistorySafeLine 'git status --short')) { throw 'safe command blocked from history' } + } Invoke-CommandProbe -Command 'uuid' -Code { uuid | Out-Null } -SkipReason $clipboardSkipReason Invoke-CommandProbe -Command 'epoch' -Code { $now = [int64]((epoch | Out-String).Trim()) @@ -807,7 +812,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 } @@ -1094,6 +1099,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' @@ -1111,9 +1117,11 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { 'Write-JournalLine' 'Invoke-PromptStage' 'Invoke-ProfileHook' + 'Test-ProfileHistorySafeLine' 'Save-TrustedDirectories' 'Read-UserSettingsForWrite' 'Get-WindowsTerminalSettingsPath' + 'Get-WindowsTerminalSettingsPaths' 'Push-TabTitle' 'Pop-TabTitle' 'Resolve-WslUncPath' diff --git a/tests/test.ps1 b/tests/test.ps1 index dde6f03..eebe03f 100644 --- a/tests/test.ps1 +++ b/tests/test.ps1 @@ -480,12 +480,25 @@ try { $requiredFunctions = @( 'Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', 'Install-WingetPackage', 'Merge-JsonObject', 'Select-PreferredEditor', - 'Invoke-DownloadWithRetry' + '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" @@ -1178,7 +1191,7 @@ T 'Invoke-ProfileWizard' { $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', 'SkipHashCheck', 'WhatIf')) { + foreach ($p in @('Resume', 'NoElevate', 'ExpectedSha256', 'BundleExpectedSha256', 'SkipHashCheck', 'WhatIf')) { if (-not $cmd.Parameters.ContainsKey($p)) { throw "Invoke-ProfileWizard missing parameter: $p" } } } @@ -1363,12 +1376,17 @@ 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' } + if (-not (Test-ProfileHistorySafeLine 'git status --short')) { throw 'safe command blocked from history' } +} 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 + uploads file' +T 'vtscan' $null 'needs API key; -Upload submits file content' T 'vt' { if (Get-Command vt.exe -ErrorAction SilentlyContinue) { vt --help } else { vt } @@ -1892,6 +1910,7 @@ try { # Internal helper functions that are intentionally not direct user commands $internalOnly = @( 'Get-ExternalCommandPath' + 'Update-SessionPathFromRegistry' 'Get-OhMyPoshInstallInfo' 'Get-OhMyPoshMsiProductCode' 'Get-OhMyPoshExecutablePath' @@ -1909,9 +1928,11 @@ try { 'Write-JournalLine' 'Invoke-PromptStage' 'Invoke-ProfileHook' + 'Test-ProfileHistorySafeLine' 'Save-TrustedDirectories' 'Read-UserSettingsForWrite' 'Get-WindowsTerminalSettingsPath' + 'Get-WindowsTerminalSettingsPaths' 'Push-TabTitle' 'Pop-TabTitle' 'Resolve-WslUncPath' From dbfb67251c22a4e6c6b822619c3f459c35b93440 Mon Sep 17 00:00:00 2001 From: 26zl <143036376+26zl@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:54:12 +0200 Subject: [PATCH 6/6] fix: production-readiness hardening across CI, installer, and profile --- .github/workflows/ci.yml | 19 +- Microsoft.PowerShell_profile.ps1 | 397 ++++++++++++++++++++++--------- README.md | 71 +++++- setup.ps1 | 65 ++--- tests/ci-functional.ps1 | 23 +- tests/test.ps1 | 89 ++++++- 6 files changed, 507 insertions(+), 157 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39c04fa..b250335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,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 diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index e8181ca..c6bc7d3 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -17,12 +17,24 @@ $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 @@ -33,6 +45,11 @@ $jsoncCommentPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*`$" # 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) +# 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). # VerCmd: argument(s) to get the tool version for pre/post-upgrade display. @@ -193,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( @@ -936,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 @@ -1126,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 } @@ -1243,7 +1317,7 @@ function Update-Profile { # 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 { @@ -1333,16 +1407,10 @@ 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) { - try { - $_upOwner = ($repo_root -replace '^https?://(raw\.)?githubusercontent\.com/', '').Trim('/') - $_upApi = "https://api.github.com/repos/$_upOwner/$repo_name/commits/main" - $_upResp = Invoke-RestMethod -Uri $_upApi -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop - if ($_upResp -and $_upResp.sha) { - $_upBaseline = Join-Path $cacheDir 'applied-commit.sha' - [System.IO.File]::WriteAllText($_upBaseline, $_upResp.sha, [System.Text.UTF8Encoding]::new($false)) - } + $_upSha = Get-LatestMainCommitSha + if ($_upSha) { + try { Write-Utf8FileAtomic (Join-Path $cacheDir 'applied-commit.sha') $_upSha } catch { $null = $_ } } - catch { $null = $_ } } # Restart the terminal whenever *anything* that the running session would load @@ -1518,10 +1586,11 @@ 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) { # System paths affect every user on the box and require admin. SupportsShouldProcess @@ -1538,12 +1607,16 @@ function Clear-Cache { return } } - $targets += @( - @{ Name = "Windows Temp"; Path = "$env:SystemRoot\Temp\*"; Recurse = $true }, - @{ Name = "Windows Prefetch"; Path = "$env:SystemRoot\Prefetch\*"; Recurse = $false } - ) + 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 @@ -1678,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 { @@ -1690,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 @@ -1713,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 @@ -2099,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 @@ -2146,6 +2242,7 @@ function trash($path) { } $shell = New-Object -ComObject 'Shell.Application' + $folder = $null; $shellItem = $null try { $folder = $shell.NameSpace($parentPath) if (-not $folder) { @@ -2161,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) } + } } } @@ -2402,11 +2502,21 @@ function genpass { finally { $rng.Dispose() } } $password = $result.ToString() - Set-Clipboard $password - Write-Host "Password copied to clipboard." -ForegroundColor Green + # 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. The clipboard is the sole delivery channel. + # clipboard-only contract. return } @@ -2453,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 -UseBasicParsing + $report = Invoke-RestMethod -Uri "https://www.virustotal.com/api/v3/files/$sha" -Headers $headers -ErrorAction Stop -UseBasicParsing -TimeoutSec 30 $found = $true } catch { @@ -2509,7 +2619,7 @@ function vtscan { $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 -UseBasicParsing).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 } @@ -2525,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." @@ -3085,7 +3197,7 @@ function Invoke-ProfileWizard { } else { Write-Host "Launching elevated wizard in a new window ..." -ForegroundColor Cyan - $proc = Start-Process -FilePath $pwshExe -ArgumentList $shellArgs -Verb RunAs -Wait -PassThru + $proc = Start-Process -FilePath $pwshExe -ArgumentList (ConvertTo-NativeArgumentLine $shellArgs) -Verb RunAs -Wait -PassThru $exitCode = if ($proc) { $proc.ExitCode } else { 1 } } @@ -3158,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) { @@ -3525,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 -FilePath cmd.exe -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 -FilePath $editorPath -ArgumentList @($hostsPath) -Verb RunAs + Start-Process -FilePath $editorPath -ArgumentList (ConvertTo-NativeArgumentLine (@($script:ResolvedEditorArgs) + @($hostsPath))) -Verb RunAs } } @@ -3823,7 +3942,7 @@ function Find-FileLocker { # 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, DefaultParameterSetName = 'ByName')] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByName', ValueFromPipeline)] [string[]]$Name, @@ -3849,6 +3968,11 @@ function Stop-StuckProcess { 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 @@ -4130,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++ @@ -4232,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 } } @@ -5303,9 +5434,29 @@ function Test-ProfileHistorySafeLine { '(?i)credential' '(?i)bearer' '(?i)\b(VTCLI_APIKEY|VT_API_KEY)\b' - '(?i)^\s*pwnd\s+' - '(?i)^\s*jwtd\s+' + # 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 } @@ -5313,6 +5464,22 @@ function Test-ProfileHistorySafeLine { 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). @@ -5331,7 +5498,10 @@ try { # 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-Content $_userSettingsForRL -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $_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) { @@ -5348,7 +5518,15 @@ $PSReadLineOptions = @{ BellStyle = 'None' } if ($_readlineColors) { $PSReadLineOptions.Colors = $_readlineColors } -Set-PSReadLineOption @PSReadLineOptions +# 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)) { @@ -5780,7 +5958,7 @@ function Start-ProfileTour { } Write-Host '' Write-Host 'Extend the profile:' -ForegroundColor Cyan - Write-Host ' profile_user.ps1 - dot-sourced last; persistent overrides' + 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)' @@ -5931,6 +6109,21 @@ function Set-TerminalBackground { } } + # 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 { @@ -5944,18 +6137,17 @@ function Set-TerminalBackground { if (-not $settings.PSObject.Properties['defaults']) { $settings | Add-Member -NotePropertyName 'defaults' -NotePropertyValue ([PSCustomObject]@{}) -Force } - foreach ($p in $bgProps) { - if ($settings.defaults.PSObject.Properties[$p]) { $settings.defaults.PSObject.Properties.Remove($p) } + if ($Clear) { + foreach ($p in $bgProps) { + if ($settings.defaults.PSObject.Properties[$p]) { $settings.defaults.PSObject.Properties.Remove($p) } + } } - if (-not $Clear) { - $settings.defaults | Add-Member -NotePropertyName 'backgroundImage' -NotePropertyValue $resolved -Force - $settings.defaults | Add-Member -NotePropertyName 'backgroundImageOpacity' -NotePropertyValue $Opacity -Force - $settings.defaults | Add-Member -NotePropertyName 'backgroundImageStretchMode' -NotePropertyValue $StretchMode -Force - $settings.defaults | Add-Member -NotePropertyName 'backgroundImageAlignment' -NotePropertyValue $Alignment -Force + else { + & $applyBg $settings.defaults } if ($PSCmdlet.ShouldProcess($settingsPath, 'Persist terminal background in user-settings.json')) { $json = $settings | ConvertTo-Json -Depth 10 - [System.IO.File]::WriteAllText($settingsPath, $json, [System.Text.UTF8Encoding]::new($false)) + Write-Utf8FileAtomic $settingsPath $json } # 2. Apply live to WT settings.json so change is visible immediately. Iterate ALL installed @@ -5968,22 +6160,21 @@ function Set-TerminalBackground { } foreach ($wtSettingsPath in $wtSettingsPaths) { try { - $wtRaw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncCommentPattern, '' + $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 } - foreach ($p in $bgProps) { - if ($wt.profiles.defaults.PSObject.Properties[$p]) { $wt.profiles.defaults.PSObject.Properties.Remove($p) } + if ($Clear) { + foreach ($p in $bgProps) { + if ($wt.profiles.defaults.PSObject.Properties[$p]) { $wt.profiles.defaults.PSObject.Properties.Remove($p) } + } } - if (-not $Clear) { - $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImage' -NotePropertyValue $resolved -Force - $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImageOpacity' -NotePropertyValue $Opacity -Force - $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImageStretchMode' -NotePropertyValue $StretchMode -Force - $wt.profiles.defaults | Add-Member -NotePropertyName 'backgroundImageAlignment' -NotePropertyValue $Alignment -Force + else { + & $applyBg $wt.profiles.defaults } if ($PSCmdlet.ShouldProcess($wtSettingsPath, 'Apply terminal background live')) { $wtJson = $wt | ConvertTo-Json -Depth 100 - [System.IO.File]::WriteAllText($wtSettingsPath, $wtJson, [System.Text.UTF8Encoding]::new($false)) + Write-Utf8FileAtomic $wtSettingsPath $wtJson } } catch { @@ -6002,7 +6193,7 @@ function Set-TerminalBackground { function Read-UserSettingsForWrite { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return [PSCustomObject]@{} } - $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + $raw = Get-Utf8FileText $Path if ([string]::IsNullOrWhiteSpace($raw)) { return [PSCustomObject]@{} } return $raw | ConvertFrom-Json -ErrorAction Stop } @@ -6030,7 +6221,7 @@ function Save-TrustedDirectories { } try { $json = $settings | ConvertTo-Json -Depth 10 - [System.IO.File]::WriteAllText($settingsPath, $json, [System.Text.UTF8Encoding]::new($false)) + Write-Utf8FileAtomic $settingsPath $json return $true } catch { @@ -6220,7 +6411,7 @@ ${g}Set-TerminalBackground${r} [-Opacity 0.3] [-StretchMode ...] [-Align ${g}Set-TerminalBackground${r} -Clear - Remove the background image. Extend the profile without forking: - ${m}profile_user.ps1${r} - dot-sourced last; PS-level overrides. + ${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). @@ -6391,20 +6582,15 @@ foreach ($entry in $script:_seedCommands) { } Remove-Variable -Name _seedCommands -Scope Script -ErrorAction SilentlyContinue -# User overrides (survives Update-Profile) -$userProfile = Join-Path (Split-Path $PROFILE) "profile_user.ps1" -if (Test-Path $userProfile) { - try { . $userProfile } - catch { Write-Warning "Failed to load profile_user.ps1: $_" } -} - # Consume user-settings.json: feature toggles, command overrides, trusted directories. -# This runs AFTER profile_user.ps1 so explicit PS-level overrides still win over JSON commandOverrides. +# 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-Content $userSettingsPath -Raw -ErrorAction Stop + $_rawSettings = Get-Utf8FileText $userSettingsPath if (-not [string]::IsNullOrWhiteSpace($_rawSettings)) { $script:UserSettings = $_rawSettings | ConvertFrom-Json -ErrorAction Stop } @@ -6412,23 +6598,15 @@ if (Test-Path $userSettingsPath) { catch { Write-Warning "user-settings.json unreadable: $($_.Exception.Message)" } } if ($script:UserSettings) { - if ($script:UserSettings.PSObject.Properties['features']) { - foreach ($prop in $script:UserSettings.features.PSObject.Properties) { - if ($script:PSP.Features.ContainsKey($prop.Name)) { - # Handle string "false"/"0" -> $false explicitly. Bare [bool] coerces any non-empty string to $true. - $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 - } - } - } - } + # 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 { $_ })) { - [void]$script:PSP.TrustedDirs.Add([string]$d) + # 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']) { @@ -6465,6 +6643,15 @@ if ($script:UserSettings) { } } +# 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' @@ -6498,15 +6685,7 @@ if ($isInteractive -and $script:PSP.Features.updateCheck) { } } if ($_ucDoCheck) { - # $repo_root is "https://raw.githubusercontent.com/"; derive owner for the API URL. - $_ucOwner = ($repo_root -replace '^https?://(raw\.)?githubusercontent\.com/', '').Trim('/') - $_ucApi = "https://api.github.com/repos/$_ucOwner/$repo_name/commits/main" - $_ucLatest = $null - try { - $_ucResp = Invoke-RestMethod -Uri $_ucApi -TimeoutSec 3 -UseBasicParsing -ErrorAction Stop - $_ucLatest = $_ucResp.sha - } - catch { $null = $_ } + $_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 } diff --git a/README.md b/README.md index 7929a2d..0831232 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,54 @@ [![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)](#) +[![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. + + ```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 ``` -Run that in an **elevated** PowerShell window. `-SkipHashCheck` is the explicit trust-on-download path; omit it to print the install-bundle SHA256 and stop before making changes, then re-run with `-ExpectedSha256 ''` after verifying what you want to pin. 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). +> **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). + +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). + +## Contents + +- [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) + +## Requirements + +| | | +| --- | --- | +| **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 @@ -122,7 +158,7 @@ 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 @@ -134,7 +170,7 @@ Four extension points survive updates. From simplest to most powerful: - `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 dot-sourced last: aliases, functions, editor, colors, modules +- **`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 @@ -401,13 +437,38 @@ Everything in `tests/` is tracked and runs locally in seconds. 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). -- `psp doctor` command - runs `Test-Profile` + environment checks + auto-fixes common issues. +- 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 diff --git a/setup.ps1 b/setup.ps1 index e5a8112..c2a4b6f 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -34,6 +34,13 @@ 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 @@ -473,6 +480,18 @@ function Start-InstallWizard { 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 } @@ -910,24 +929,9 @@ function Install-NerdFonts { } } -# Resolve the active Windows Terminal settings.json across install variants. -# DUPLICATED from Microsoft.PowerShell_profile.ps1's Get-WindowsTerminalSettingsPath. -# Keep these two copies in sync per CLAUDE.md "Structural Duplication" guidance. -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 -} - # 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' @@ -1224,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 - if ($PSCommandPath) { exit 1 } else { 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 "" @@ -1814,14 +1820,19 @@ elseif ($canPromptTelemetry -and $isElevatedSetup -and -not [System.Environment] } } -# 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) @@ -1845,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/tests/ci-functional.ps1 b/tests/ci-functional.ps1 index 4b5a367..7c3e8ac 100644 --- a/tests/ci-functional.ps1 +++ b/tests/ci-functional.ps1 @@ -777,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() @@ -795,7 +799,15 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { 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' } - if (-not (Test-ProfileHistorySafeLine 'git status --short')) { throw 'safe command blocked from 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 { @@ -1126,6 +1138,11 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { '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/test.ps1 b/tests/test.ps1 index eebe03f..84e8524 100644 --- a/tests/test.ps1 +++ b/tests/test.ps1 @@ -271,14 +271,25 @@ catch { Write-Result 'PSScriptAnalyzer' 'FAIL' $_.Exception.Message } # 3. Smoke test (pwsh, non-interactive) # ------------------------------------------------------- Write-Host '[3/26] Smoke test (pwsh)' -ForegroundColor Cyan -try { - $env:CI = 'true' - pwsh -NonInteractive -NoProfile -Command ". '$profilePath'" - if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } - Write-Result 'Smoke test (pwsh)' 'PASS' +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 } } -catch { Write-Result 'Smoke test (pwsh)' 'FAIL' $_.Exception.Message } -finally { $env:CI = $null } # ------------------------------------------------------- # 4. Smoke test (PS5, non-interactive) @@ -290,8 +301,10 @@ if ($SkipPS5 -or -not (Get-Command powershell.exe -ErrorAction SilentlyContinue) else { try { $escaped = $profilePath -replace "'", "''" - powershell.exe -NoProfile -Command "`$env:CI = 'true'; . '$escaped'" + $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 } @@ -735,6 +748,11 @@ foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-conf [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)) @@ -768,6 +786,9 @@ try { # 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' @@ -790,6 +811,9 @@ try { # 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' @@ -1379,7 +1403,18 @@ T 'jwtd' { jwtd "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3O 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' } - if (-not (Test-ProfileHistorySafeLine 'git status --short')) { throw 'safe command blocked from 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 } @@ -1659,6 +1694,37 @@ T 'Merge-JsonObject (setup copy)' { 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' @@ -1937,6 +2003,11 @@ try { 'Pop-TabTitle' 'Resolve-WslUncPath' 'Initialize-RestartManagerType' + 'ConvertTo-NativeArgumentLine' + 'Get-Utf8FileText' + 'Write-Utf8FileAtomic' + 'Set-PspFeatureOverride' + 'Get-LatestMainCommitSha' ) $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ }