diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f73bd36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# Editor settings matching .gitattributes and the CI checks (ASCII .ps1 files without BOM, CRLF for PowerShell, LF elsewhere). +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{ps1,psm1,psd1}] +end_of_line = crlf + +[*.{json,md,yml,yaml}] +end_of_line = lf +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 323d2ae..6a5dabe 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,6 @@ contact_links: - name: Security Vulnerability url: https://github.com/26zl/PowerShellPerfect/security/advisories/new about: Report security issues privately via GitHub Security Advisories - - name: General Questions - url: https://github.com/26zl/PowerShellPerfect/discussions - about: Ask general usage questions in GitHub Discussions + - name: Usage questions and troubleshooting + url: https://github.com/26zl/PowerShellPerfect#troubleshooting + about: Check the troubleshooting table and run psp-doctor; if that does not help, open a bug report diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ed7590a..c3f480f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,10 +13,14 @@ ## Checklist -- [ ] PSScriptAnalyzer passes with no warnings or errors +- [ ] `tests/lint.ps1` passes with no warnings or errors +- [ ] `tests/unit.ps1` passes on PowerShell 5.1 and 7+ +- [ ] Bug fix: a regression case in `tests/unit.ps1` fails without the fix - [ ] Tested on PowerShell 5.1 (if script behavior changed) - [ ] Tested on PowerShell 7+ (if script behavior changed) +- [ ] User-visible change listed under **Unreleased** in `CHANGELOG.md` +- [ ] New downloads are pinned (version and SHA-256 where available); new third-party calls are mocked in CI and listed in `SECURITY.md` - [ ] No hardcoded user paths (`C:\Users\`, `/home/`) - [ ] No embedded secrets or tokens - [ ] No em dashes or non-ASCII characters in source files -- [ ] UTF-8 without BOM for all file writes (`[System.IO.File]::WriteAllText()`) +- [ ] UTF-8 without BOM for all file writes (`Write-Utf8FileAtomic` or `[System.IO.File]::WriteAllText()`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bd137c..ff567e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,62 +9,40 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# GitHub-hosted runners are Windows Server images; Windows 10/11 client behaviour is verified manually (see CONTRIBUTING.md). +# The runner image is pinned instead of windows-latest so an image upgrade cannot change results unnoticed. jobs: lint: - runs-on: windows-latest + runs-on: windows-2025 + timeout-minutes: 20 steps: - name: Harden Runner uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: - egress-policy: audit # advisory: logs egress without blocking. Flip to 'block' once the allowed-endpoints list is pinned from audit output. + # Windows runners support audit mode only (block mode needs a Linux runner), so egress is logged, not blocked. + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install PSScriptAnalyzer shell: pwsh - run: Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.24.0 -Force -Scope CurrentUser + run: Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.25.0 -Force -Scope CurrentUser - - name: Run PSScriptAnalyzer + - name: Run PSScriptAnalyzer (PSScriptAnalyzerSettings.psd1) shell: pwsh - run: | - $results = Invoke-ScriptAnalyzer -Path . -Recurse -ExcludeRule @( - 'PSAvoidUsingWriteHost' # Profile needs colored console output - 'PSAvoidUsingWMICmdlet' # PS5 compatibility path in uptime - 'PSUseShouldProcessForStateChangingFunctions' # Overkill for a profile - 'PSUseBOMForUnicodeEncodedFile' # Not needed - 'PSReviewUnusedParameter' # Scriptblock params required by completer API - 'PSUseSingularNouns' # Style preference - ) | 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" - exit 1 - } + # tests/ is excluded: those files call profile aliases (gc, ls, cat, uptime, eventlog) by their short names to + # exercise the profile's own definitions, and hold fixtures that trip the secret and path scanners. + run: pwsh -NoProfile -File tests/lint.ps1 -NoInstall - - name: Smoke-test profile (non-interactive) + - name: Smoke-test profile (PS7, sandboxed, load-time budget) shell: pwsh - run: | - $env:CI = 'true' - # $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 - } - 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 + run: pwsh -NoProfile -File tests/smoke.ps1 -BudgetMs 3000 - name: PS5 parse-check (all .ps1 files) shell: powershell @@ -88,25 +66,9 @@ jobs: } if ($errors -gt 0) { exit 1 } - - name: Smoke-test profile (PS5.1 runtime) + - name: Smoke-test profile (PS5.1 runtime, sandboxed, load-time budget) shell: powershell - run: | - $env:CI = 'true' - # Load the profile under Windows PowerShell 5.1 so PS5-specific runtime paths are exercised, not just parsed. - $smoke = powershell -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 under PS5.1 (exit code: $code)" - exit 1 - } - if ($smokeErrors.Count -gt 0) { - $smokeErrors | ForEach-Object { Write-Host $_ -ForegroundColor Red } - Write-Error "Profile loaded under PS5.1 but emitted $($smokeErrors.Count) error(s) during load" - exit 1 - } - Write-Host "Profile loaded successfully under PS5.1 (no errors)." -ForegroundColor Green + run: powershell -NoProfile -File tests/smoke.ps1 -BudgetMs 4000 - name: Check for hardcoded paths shell: pwsh @@ -146,7 +108,7 @@ jobs: - name: Check for non-ASCII characters in scripts shell: pwsh run: | - $files = Get-ChildItem -Path '${{ github.workspace }}' -Filter *.ps1 -Recurse + $files = Get-ChildItem -Path '${{ github.workspace }}' -Include *.ps1,*.psd1 -Recurse $found = @() foreach ($file in $files) { $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue @@ -170,7 +132,7 @@ jobs: - name: Check for UTF-8 BOM shell: pwsh run: | - $files = Get-ChildItem -Recurse -Include *.ps1,*.json | Where-Object { $_.FullName -notlike '*\.git\*' } + $files = Get-ChildItem -Recurse -Include *.ps1,*.psd1,*.json | Where-Object { $_.FullName -notlike '*\.git\*' } $found = @() foreach ($file in $files) { $bytes = [System.IO.File]::ReadAllBytes($file.FullName) @@ -223,7 +185,7 @@ jobs: '(?i)connectionstring\s*[:=]\s*[''"]Server=' ) # 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' } + $files = Get-ChildItem -Recurse -Include *.ps1,*.psd1,*.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 @@ -245,14 +207,18 @@ jobs: Write-Host "No secrets found." -ForegroundColor Green install-flow: - runs-on: windows-latest + runs-on: windows-2025 + timeout-minutes: 20 steps: - name: Harden Runner uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: - egress-policy: audit # advisory: logs egress without blocking. Flip to 'block' once the allowed-endpoints list is pinned from audit output. + # Windows runners support audit mode only (block mode needs a Linux runner), so egress is logged, not blocked. + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Validate JSON configs parse correctly shell: pwsh @@ -269,21 +235,22 @@ jobs: } if ($errors -gt 0) { exit 1 } - - name: Validate config schema (required keys) + - name: Validate config schema (required keys and pins) shell: pwsh run: | $theme = Get-Content 'theme.json' -Raw | ConvertFrom-Json $terminal = Get-Content 'terminal-config.json' -Raw | ConvertFrom-Json $errors = @() - # theme.json must have theme.name and theme.url if (-not $theme.theme.name) { $errors += 'theme.json: missing theme.name' } if (-not $theme.theme.url) { $errors += 'theme.json: missing theme.url' } - # terminal-config.json must have defaults and fontInstall + if ($theme.theme.url -match '/main/') { $errors += 'theme.json: theme.url follows a branch instead of a release tag' } + if ($theme.theme.sha256 -notmatch '^[A-Fa-f0-9]{64}$') { $errors += 'theme.json: theme.sha256 missing or malformed' } if (-not $terminal.defaults) { $errors += 'terminal-config.json: missing defaults' } if (-not $terminal.fontInstall) { $errors += 'terminal-config.json: missing fontInstall' } if (-not $terminal.fontInstall.name) { $errors += 'terminal-config.json: missing fontInstall.name' } if (-not $terminal.fontInstall.displayName) { $errors += 'terminal-config.json: missing fontInstall.displayName' } - if (-not $terminal.fontInstall.version) { $errors += 'terminal-config.json: missing fontInstall.version' } + if ($terminal.fontInstall.version -notmatch '^\d+\.\d+\.\d+$') { $errors += 'terminal-config.json: fontInstall.version missing or not x.y.z' } + if ($terminal.fontInstall.sha256 -notmatch '^[A-Fa-f0-9]{64}$') { $errors += 'terminal-config.json: fontInstall.sha256 missing or malformed' } if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Host "FAIL: $_" -ForegroundColor Red } exit 1 @@ -293,35 +260,16 @@ jobs: - 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 - } - + . ./tests/lib/Sandbox.ps1 + . ([scriptblock]::Create((Get-PspAssignmentSource -Path 'setup.ps1' -Variable '$script:CuratedSchemes'))) + . ([scriptblock]::Create((Get-PspAssignmentSource -Path 'setup.ps1' -Variable '$script:CuratedFonts'))) $errors = 0 # Schemes: expect 7 entries, each with Name, Desc, and a nested Scheme with name + background - $schemes = $found['CuratedSchemes'] - if (-not $schemes) { Write-Host "FAIL: CuratedSchemes not found" -ForegroundColor Red; exit 1 } + $schemes = $script:CuratedSchemes if ($schemes.Count -ne 7) { Write-Host "FAIL: expected 7 curated schemes, got $($schemes.Count)" -ForegroundColor Red; $errors++ } - $schemeReq = @('Name','Desc','Scheme') $schemeInnerReq = @('name','background','foreground','black','red','green','yellow','blue','purple','cyan','white') foreach ($s in $schemes) { - foreach ($k in $schemeReq) { + foreach ($k in 'Name', 'Desc', 'Scheme') { if (-not $s.ContainsKey($k)) { Write-Host "FAIL: scheme '$($s.Name)' missing '$k'" -ForegroundColor Red; $errors++ } } if ($s.Scheme) { @@ -334,168 +282,65 @@ jobs: } } Write-Host "OK: $($schemes.Count) curated schemes validated" -ForegroundColor Green - - # Fonts: expect 6 entries, each with Name (wizard menu label), Asset, DisplayName, Desc - $fonts = $found['CuratedFonts'] - if (-not $fonts) { Write-Host "FAIL: CuratedFonts not found" -ForegroundColor Red; exit 1 } + # Fonts: expect 6 entries, each with the menu label, release asset, display name, description and pinned hash + $fonts = $script:CuratedFonts if ($fonts.Count -ne 6) { Write-Host "FAIL: expected 6 curated fonts, got $($fonts.Count)" -ForegroundColor Red; $errors++ } - $fontReq = @('Name','Asset','DisplayName','Desc') foreach ($f in $fonts) { - foreach ($k in $fontReq) { + foreach ($k in 'Name', 'Asset', 'DisplayName', 'Desc', 'Sha256') { if (-not $f.ContainsKey($k) -or [string]::IsNullOrWhiteSpace([string]$f[$k])) { Write-Host "FAIL: font '$($f.Asset)' missing '$k'" -ForegroundColor Red; $errors++ } } + if ($f.Sha256 -notmatch '^[A-F0-9]{64}$') { Write-Host "FAIL: font '$($f.Asset)' hash malformed" -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) + - name: setup.ps1 structure (copies match the profile, verification before changes) shell: pwsh run: | - # Source setup.ps1 functions without running the main install flow - # by extracting and testing the helper functions in isolation - $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', 'Remove-SafeTempDirectory', 'Update-SessionPathFromRegistry') - $errors = 0 - foreach ($fn in $requiredFunctions) { - if ($content -notmatch "function\s+$fn\b") { - Write-Host "FAIL: setup.ps1 missing function $fn" -ForegroundColor Red - $errors++ - } else { - Write-Host "OK: $fn defined" -ForegroundColor Green - } - } - if ($errors -gt 0) { exit 1 } - - - name: Test Merge-JsonObject logic - shell: pwsh - run: | - # Extract Merge-JsonObject from setup.ps1 and test it (with null guard like production) - function Merge-JsonObject($base, $override) { - 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]) { - Merge-JsonObject $baseVal.Value $prop.Value - } else { - $base | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force - } - } - } - - # Test 1: flat merge - $base = [PSCustomObject]@{ a = 1; b = 2 } - $override = [PSCustomObject]@{ b = 99; c = 3 } - Merge-JsonObject $base $override - if ($base.a -ne 1 -or $base.b -ne 99 -or $base.c -ne 3) { - Write-Error "Flat merge failed: $($base | ConvertTo-Json -Compress)" - exit 1 - } - Write-Host "OK: flat merge" -ForegroundColor Green - - # Test 2: deep merge preserves nested keys - $base = [PSCustomObject]@{ font = [PSCustomObject]@{ face = "Consolas"; size = 11 }; opacity = 75 } - $override = [PSCustomObject]@{ font = [PSCustomObject]@{ size = 14 } } - Merge-JsonObject $base $override - if ($base.font.face -ne "Consolas" -or $base.font.size -ne 14 -or $base.opacity -ne 75) { - Write-Error "Deep merge failed: $($base | ConvertTo-Json -Compress)" - exit 1 - } - Write-Host "OK: deep merge" -ForegroundColor Green - - # Test 3: override replaces non-object with object - $base = [PSCustomObject]@{ theme = "simple" } - $override = [PSCustomObject]@{ theme = [PSCustomObject]@{ name = "pure" } } - Merge-JsonObject $base $override - if ($base.theme.name -ne "pure") { - Write-Error "Object replacement failed: $($base | ConvertTo-Json -Compress)" - exit 1 - } - Write-Host "OK: object replacement" -ForegroundColor Green - - # Test 4: null override is a no-op; null base throws (caller-bug surfacing) - $base = [PSCustomObject]@{ a = 1 } - Merge-JsonObject $base $null - 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) + . ./tests/lib/Sandbox.ps1 + $problems = @() + $setup = Get-Content 'setup.ps1' -Raw + foreach ($fn in 'Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', 'Install-WingetPackage', 'Select-PreferredEditor', 'Remove-SafeTempDirectory', 'Initialize-InstallBundle', 'Save-WizardChoices', 'Resolve-WizardState', 'Invoke-SetupInstall') { + if ($setup -notmatch "function\s+$fn\b") { $problems += "missing function $fn" } + } + # setup.ps1 needs these before the bundle is verified, so it carries copies that must match the profile exactly. + foreach ($fn in 'Write-PspHost', 'Invoke-DownloadWithRetry', 'Get-CombinedSha256', 'Resolve-PspCommitSha', 'Get-PspReleaseChecksums') { + $a = (Get-PspFunctionSource -Path 'setup.ps1' -Name $fn) -replace "`r`n", "`n" + $b = (Get-PspFunctionSource -Path 'Microsoft.PowerShell_profile.ps1' -Name $fn) -replace "`r`n", "`n" + if ($a -cne $b) { $problems += "$fn in setup.ps1 differs from the profile" } + } + # Everything else setup.ps1 loads from the verified profile must exist there. + . ([scriptblock]::Create((Get-PspAssignmentSource -Path 'setup.ps1' -Variable '$script:SharedFunctionNames'))) + try { $null = Get-PspFunctionSource -Path 'Microsoft.PowerShell_profile.ps1' -Name $script:SharedFunctionNames } catch { $problems += $_.Exception.Message } + if ($setup.IndexOf('$script:Bundle = Initialize-InstallBundle') -gt $setup.IndexOf('$installed = Invoke-SetupInstall')) { $problems += 'bundle verification does not come before installation' } + if ($problems) { $problems | ForEach-Object { Write-Host "FAIL: $_" -ForegroundColor Red }; exit 1 } + Write-Host 'setup.ps1 structure OK' -ForegroundColor Green + + - name: Regression tests (PS7, tests/unit.ps1) shell: pwsh - run: | - # Simulate the WT settings update flow with mock data - $mockWt = [PSCustomObject]@{ - profiles = [PSCustomObject]@{ - defaults = [PSCustomObject]@{ font = [PSCustomObject]@{ face = "Consolas"; size = 10 } } - list = @() - } - schemes = @() - actions = @() - } - $theme = Get-Content 'theme.json' -Raw | ConvertFrom-Json - $terminal = Get-Content 'terminal-config.json' -Raw | ConvertFrom-Json - $defaults = $mockWt.profiles.defaults - - # Apply terminal defaults - $terminal.defaults.PSObject.Properties | ForEach-Object { - $defaults | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value -Force - } - - # Apply theme colors - if ($theme.windowsTerminal.colorScheme) { - $defaults | Add-Member -NotePropertyName "colorScheme" -NotePropertyValue $theme.windowsTerminal.colorScheme -Force - } - - # Verify results (read expected values from config, not hardcoded) - $errors = 0 - if ($defaults.opacity -ne $terminal.defaults.opacity) { Write-Host "FAIL: opacity=$($defaults.opacity), expected $($terminal.defaults.opacity)" -ForegroundColor Red; $errors++ } - if ($defaults.colorScheme -ne $theme.windowsTerminal.colorScheme) { Write-Host "FAIL: colorScheme=$($defaults.colorScheme), expected $($theme.windowsTerminal.colorScheme)" -ForegroundColor Red; $errors++ } - if ($defaults.font.face -ne $terminal.defaults.font.face) { Write-Host "FAIL: font.face=$($defaults.font.face), expected $($terminal.defaults.font.face)" -ForegroundColor Red; $errors++ } - - # Verify 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) { - Write-Host "FAIL: scheme upsert" -ForegroundColor Red; $errors++ - } + run: pwsh -NoProfile -File tests/unit.ps1 -AllowElevated - # Verify keybinding upsert - foreach ($kb in $terminal.keybindings) { - $bindingId = "User.profile.$($kb.keys -replace '[^a-zA-Z0-9]', '')" - $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) { - Write-Host "FAIL: keybinding upsert (expected keys=$($firstKb.keys) command=$($firstKb.command))" -ForegroundColor Red; $errors++ - } - - if ($errors -gt 0) { exit 1 } - - # 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 + - name: Regression tests (PS5.1, tests/unit.ps1) + shell: powershell + run: powershell -NoProfile -File tests/unit.ps1 -AllowElevated functional: - runs-on: windows-latest + runs-on: windows-2025 + timeout-minutes: 30 needs: [lint, install-flow] steps: - name: Harden Runner uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: - egress-policy: audit # advisory: logs egress without blocking. Flip to 'block' once the allowed-endpoints list is pinned from audit output. + # Windows runners support audit mode only (block mode needs a Linux runner), so egress is logged, not blocked. + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Run functional profile tests + - name: Run functional profile tests (third-party endpoints mocked) shell: pwsh - run: | - pwsh -NoProfile -File '${{ github.workspace }}/tests/ci-functional.ps1' + run: pwsh -NoProfile -File '${{ github.workspace }}/tests/ci-functional.ps1' -AllowElevated diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2e85441 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release + +# Push a tag vX.Y.Z (matching Version in the profile) to publish a release with SHA256SUMS.txt and a build provenance +# attestation. setup.ps1 -Ref vX.Y.Z and Update-Profile -Ref vX.Y.Z verify their downloads against that SHA256SUMS.txt. +on: + push: + tags: ['v*.*.*'] + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write # create the release and upload its files + id-token: write # sign the build provenance attestation + attestations: write # store the attestation + steps: + - name: Harden Runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + # Audit first; switch to block with the endpoints from the first run's insights. + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build release files from the tagged commit + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # Release files are the committed blobs (LF line endings), byte-identical to what raw.githubusercontent.com serves + # for this tag; a checkout would convert .ps1 files to CRLF and change their hashes. + mkdir -p dist + files="setup.ps1 setprofile.ps1 Microsoft.PowerShell_profile.ps1 theme.json terminal-config.json" + for f in $files; do git cat-file blob "${GITHUB_SHA}:${f}" > "dist/${f}"; done + version=$(grep -oP "^\s*Version\s*=\s*'\K[0-9]+\.[0-9]+\.[0-9]+" dist/Microsoft.PowerShell_profile.ps1 | head -n 1) + if [ "v${version}" != "${TAG}" ]; then + echo "::error::Tag ${TAG} does not match Version '${version}' in Microsoft.PowerShell_profile.ps1" + exit 1 + fi + (cd dist && sha256sum $files > SHA256SUMS.txt) + hash() { sha256sum "dist/$1" | cut -d' ' -f1 | tr 'a-f' 'A-F'; } + bundle=$(printf 'profile:%s:theme:%s:terminal:%s' "$(hash Microsoft.PowerShell_profile.ps1)" "$(hash theme.json)" "$(hash terminal-config.json)" | sha256sum | cut -d' ' -f1 | tr 'a-f' 'A-F') + # Release notes: the CHANGELOG section for this version plus how to verify and install. + awk -v v="${version}" '$0 ~ "^## \\[" v "\\]" {found=1; next} found && /^## \[/ {exit} found {print}' CHANGELOG.md > notes.md + if [ ! -s notes.md ]; then + echo "::error::CHANGELOG.md has no section for ${version}" + exit 1 + fi + { + echo + echo "## Verify and install" + echo + echo "Each file's SHA-256 is in \`SHA256SUMS.txt\`; \`gh attestation verify --repo ${GITHUB_REPOSITORY}\` checks that it was built here from this tag." + echo "\`setup.ps1 -Ref ${TAG}\` and \`Update-Profile -Ref ${TAG}\` check their downloads against \`SHA256SUMS.txt\` automatically." + echo "Install bundle hash (\`-ExpectedSha256\`): \`${bundle}\`" + echo + echo "See the README for the full install command." + } >> notes.md + cat dist/SHA256SUMS.txt + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: 'dist/*' + + - name: Publish the release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # Upload the files to a draft first, so an immutable release is published complete. + gh release create "${TAG}" dist/* --repo "${GITHUB_REPOSITORY}" --draft --verify-tag --title "PowerShellPerfect ${TAG}" --notes-file notes.md + gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml new file mode 100644 index 0000000..6ccd0b4 --- /dev/null +++ b/.github/workflows/scheduled.yml @@ -0,0 +1,98 @@ +name: Scheduled checks + +# Pull-request CI mocks third-party web services; this workflow covers what that leaves out. +on: + schedule: + - cron: '17 4 * * 1' # Mondays 04:17 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + live-network: + runs-on: windows-2025 + timeout-minutes: 30 + steps: + - name: Harden Runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + # Windows runners support audit mode only (block mode needs a Linux runner), so egress is logged, not blocked. + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Functional tests against the real endpoints (no public paste upload) + shell: pwsh + run: pwsh -NoProfile -File tests/ci-functional.ps1 -AllowElevated -LiveNetwork + + pins: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden Runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Pinned downloads still match their hashes + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + . ./tests/lib/Sandbox.ps1 + foreach ($v in '$script:NerdFontVersion', '$script:CuratedFonts', '$script:OmpThemesRef', '$script:PinnedOmpThemes') { + . ([scriptblock]::Create((Get-PspAssignmentSource -Path 'setup.ps1' -Variable $v))) + } + . ([scriptblock]::Create((Get-PspFunctionSource -Path 'Microsoft.PowerShell_profile.ps1' -Name 'Get-PspComponentPins'))) + $problems = @() + $work = Join-Path ([System.IO.Path]::GetTempPath()) 'psp-pins' + New-Item -ItemType Directory -Path $work -Force | Out-Null + + # Nerd Fonts: the curated fonts and terminal-config.json against the release's own SHA-256.txt. + $sumsFile = Join-Path $work 'nf-sums.txt' + Invoke-WebRequest "https://github.com/ryanoasis/nerd-fonts/releases/download/v$($script:NerdFontVersion)/SHA-256.txt" -OutFile $sumsFile + $nf = @{} + foreach ($line in Get-Content $sumsFile) { if ($line -match '^([0-9a-f]{64})\s+(\S+)$') { $nf[$Matches[2]] = $Matches[1].ToUpperInvariant() } } + foreach ($font in $script:CuratedFonts) { + if ($nf["$($font.Asset).zip"] -ne $font.Sha256) { $problems += "setup.ps1 hash for $($font.Asset).zip does not match Nerd Fonts v$($script:NerdFontVersion)" } + } + $fontInstall = (Get-Content 'terminal-config.json' -Raw | ConvertFrom-Json).fontInstall + if ($fontInstall.version -ne $script:NerdFontVersion) { $problems += "terminal-config.json font version $($fontInstall.version) differs from setup.ps1 $($script:NerdFontVersion)" } + elseif ($nf["$($fontInstall.name).zip"] -ne $fontInstall.sha256.ToUpperInvariant()) { $problems += "terminal-config.json hash for $($fontInstall.name).zip does not match" } + + # Oh My Posh themes pinned in setup.ps1 and theme.json. + foreach ($name in $script:PinnedOmpThemes.Keys) { + $file = Join-Path $work "$name.omp.json" + Invoke-WebRequest "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/$($script:OmpThemesRef)/themes/$name.omp.json" -OutFile $file + if ((Get-FileHash $file -Algorithm SHA256).Hash -ne $script:PinnedOmpThemes[$name]) { $problems += "Oh My Posh theme $name changed at $($script:OmpThemesRef)" } + } + $theme = (Get-Content 'theme.json' -Raw | ConvertFrom-Json).theme + $file = Join-Path $work 'theme-json.omp.json' + Invoke-WebRequest $theme.url -OutFile $file + if ((Get-FileHash $file -Algorithm SHA256).Hash -ne $theme.sha256.ToUpperInvariant()) { $problems += "theme.json hash for $($theme.url) does not match" } + + # Pinned packages still exist. + $pins = Get-PspComponentPins + # The Packages(Id,Version) entry answers 404 for a version that does not exist (a filtered search would return an empty feed). + try { + $entry = Invoke-RestMethod "https://www.powershellgallery.com/api/v2/Packages(Id='PSFzf',Version='$($pins.PSFzf)')" + if (-not $entry) { throw 'empty response' } + } catch { $problems += "PSFzf $($pins.PSFzf) not found on the PowerShell Gallery" } + try { $null = Invoke-RestMethod "https://registry.npmjs.org/http-server/$($pins.HttpServer)" } catch { $problems += "http-server $($pins.HttpServer) not found on npm" } + + # Newer upstream releases are reported, not failed. + foreach ($repo in 'ryanoasis/nerd-fonts', 'JanDeDobbeleer/oh-my-posh') { + try { + $latest = (Invoke-RestMethod "https://api.github.com/repos/$repo/releases/latest" -Headers @{ 'User-Agent' = 'PowerShellPerfect-pins' }).tag_name + Write-Host "::notice::Latest $repo release: $latest" + } catch { Write-Host "::warning::Could not read the latest $repo release: $($_.Exception.Message)" } + } + + if ($problems) { $problems | ForEach-Object { Write-Host "::error::$_" }; exit 1 } + Write-Host 'All pinned downloads match.' diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..19b5b1b --- /dev/null +++ b/.mailmap @@ -0,0 +1 @@ +26zl <143036376+26zl@users.noreply.github.com> diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..26cf4d5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Instructions for AI coding agents + +PowerShellPerfect is a Windows PowerShell profile (`Microsoft.PowerShell_profile.ps1`), its installer (`setup.ps1`), a profile-only copier (`setprofile.ps1`), shipped config (`theme.json`, `terminal-config.json`) and test harnesses in `tests/`. Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full conventions; the rules below are the ones that are easy to get wrong. + +## Safety: this code changes real machines + +- Never run `setup.ps1` (without `-WhatIf`), `Update-Profile`, `Uninstall-Profile`, `Clear-Cache` or `Clear-ProfileCache` against the real environment. Run them only inside a sandbox from `tests/lib/Sandbox.ps1` (`New-PspSandbox` + `Enter-PspSandbox`, then dot-source `$PspPostLoadStubs` after loading the profile). +- `Uninstall-Profile -All`/`-RemoveFonts` touches the machine font store unless `$script:PSP.TestFontScopes` points into the sandbox; `$PspPostLoadStubs` sets it. +- Do not create `telemetry.owned` in a sandbox before calling `Uninstall-Profile`: uninstall would then remove the real `POWERSHELL_TELEMETRY_OPTOUT` variable. +- Run tests from a non-elevated shell. Pass `-AllowElevated` only on CI runners or disposable VMs. +- `tests/rawhunt.ps1` is not sandboxed (public paste upload, clipboard, winget); do not run it. + +## Checks to run + +```powershell +pwsh -NoProfile -File tests/unit.ps1 # and: powershell -NoProfile -File tests/unit.ps1 +pwsh -NoProfile -File tests/lint.ps1 # PSScriptAnalyzer 1.25.0 + PSScriptAnalyzerSettings.psd1 +pwsh -NoProfile -File tests/smoke.ps1 +pwsh -NoProfile -File tests/test.ps1 # full local gate +``` + +A bug fix needs a regression case in `tests/unit.ps1` that fails without the fix. + +## Code rules + +- Must run on Windows PowerShell 5.1 and PowerShell 7. `.ps1` files are ASCII only, no BOM, CRLF (`.gitattributes`). +- Read JSON with `Read-JsonFile`/`ConvertFrom-JsonC`, text with `Get-Utf8FileText`, write with `Write-Utf8FileAtomic`; use `-LiteralPath`. +- Helpers such as `Get-PspProp`, `Get-PspArray` and `Read-JsonFile` return lists with `return , $list`. Wrap such calls in parentheses before piping or counting (`(Get-PspArray $o 'k').Count`); `@(Get-PspArray ...)` nests the array. +- Windows Terminal `settings.json` is changed only through `Update-WindowsTerminalSettingsFile` / `Undo-WindowsTerminalSettingsFile`, which record each change so uninstall can revert it. +- `setup.ps1` loads the helpers named in `$script:SharedFunctionNames` from the verified profile; every profile helper that a shared function calls must be in that list (`tests/unit.ps1` checks it). The five pre-bundle helpers (`Write-PspHost`, `Invoke-DownloadWithRetry`, `Get-CombinedSha256`, `Resolve-PspCommitSha`, `Get-PspReleaseChecksums`) exist in both files and must stay identical; CI compares them. +- Destructive commands use `SupportsShouldProcess`; pin and hash anything downloaded at runtime; new third-party calls need a mock in `tests/ci-functional.ps1` and a row in `SECURITY.md`. +- Use `Write-PspHost` (honors `NO_COLOR`), keep state in `$script:PSP`, name load-time temporaries `$_psp*` (removed after load), and keep comments to one sentence about current behavior. + +## Releases + +Bump `Version` in `$script:PSP`, add a `CHANGELOG.md` section, then tag `vX.Y.Z`; `release.yml` publishes the files with `SHA256SUMS.txt` and an attestation. Do not create tags or releases unless asked. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3d71494 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/): the version is `Version` in `$script:PSP` in the profile, and releases are tagged `vX.Y.Z`. Changes before 1.0.0 are in the git history. + +## [Unreleased] + +## [1.0.0] - 2026-10-17 + +First versioned release. + +### Security + +- `.psprc.ps1` trust is bound to the file's content: `Add-TrustedDirectory` stores its SHA-256, and a changed file (for example after `git pull` or a branch checkout) no longer runs until it is approved again. **Directories trusted with an earlier version must be approved once more** with `Add-TrustedDirectory`; until then their `.psprc.ps1` is skipped with a warning, and `psp-doctor` lists them. +- Releases are tagged and published with `SHA256SUMS.txt` and a build provenance attestation. `setup.ps1 -Ref vX.Y.Z` and `Update-Profile -Ref vX.Y.Z` resolve the tag to a commit and check every downloaded file against the release's checksums before changing anything. Branches and commits still require `-ExpectedSha256` or `-SkipHashCheck`. +- `setup.ps1` no longer requires administrator rights. Unelevated, the Nerd Font and the optional telemetry opt-out are installed for the current user; elevated, they are machine-wide and the terminal is not restarted, so you are not left in an elevated shell. `Invoke-ProfileWizard` runs the downloaded setup in the current session instead of an elevated one. +- Runtime downloads are pinned: Oh My Posh themes to `v31.8.0` (default and preset themes with SHA-256), Nerd Fonts to `v3.5.1` (every curated archive's SHA-256 checked), PSFzf `2.7.12`, and `http-server` `14.1.1` for `serve`. +- `winutil` hashes the downloaded script and runs the same in-memory bytes. +- Resumed wizard state is rebuilt from the curated lists, the editor name is quoted in `profile_user.ps1`, and a folder that merely contains files with the repository's names is no longer treated as a local checkout when `-Ref` or `-ExpectedSha256` is given. +- `certcheck` and `tlscert` report name-mismatch and chain errors instead of accepting every certificate silently; `env` masks secret-looking values unless `-ShowValues`; `pwnd` prompts without echo when no argument is given. +- `nscan` prints an authorization notice and asks before its Full, Stealth and Vuln profiles. +- A `.psprc.ps1` is read once per directory change and the text that was hashed is what runs (with `$PSScriptRoot` set to its folder), so the file cannot be swapped between the check and the run. +- `winutil -ExpectedSha256` and `winutil -Force` need an elevated shell: unelevated, WinUtil relaunches itself from its latest release, which the hash check never saw. +- `Invoke-ProfileWizard -Ref vX.Y.Z` checks `setup.ps1` against the release's `SHA256SUMS.txt` and passes the tag on, so setup verifies the bundle against the same release. +- `trash` refuses in SSH, WinRM and service sessions, where Windows deletes permanently instead of recycling. +- `env` masks more secret names (`*_PASS`, `*_PWD`, `*_PASSPHRASE`, account keys, connection strings) and connection-string values (`Password=`, `AccountKey=`, SAS signatures). + +### Changed + +- `Uninstall-Profile` reverts Windows Terminal changes value by value in every installed variant (Stable, Preview, Canary, unpackaged), keeping values you changed after install. Each change is recorded in `wt-changes.json`, and the untouched pre-install file is kept as `settings.json.psp-original`. Older installs without a record restore the oldest backup and keep the current file next to it. `windowsTerminal.manage = false` is honored, `-HardResetWindowsTerminal` keeps backups unless `-RemoveUserData`, and `-RemoveUserData` also removes the `oldprofile.*.ps1` copies. Kept files are listed under **Preserved**. +- `Set-TerminalBackground` and `setup.ps1` write Windows Terminal settings through the same change recorder as `Update-Profile`; arrays the profile adds to a file that had none are removed again on uninstall. +- `Update-Profile` installs missing tools only with `-InstallMissingTools` (which accepts the winget agreements and installs the pinned PSFzf); without it, missing tools are listed. New `-Ref `. +- `setup.ps1`: strict error handling, a log in `%LOCALAPPDATA%\PowerShellProfile\logs`, `-WhatIf`, documented exit codes and `Get-Help` text; a failed backup stops the write it protects; settings are written atomically; `profile_user.ps1` is backed up before the editor line changes and is not rewritten when nothing changes; the wizard state moved to `%LOCALAPPDATA%\PowerShellProfile\wizard-state.json` and is removed only after the choices are saved. +- `setup.ps1` no longer uninstalls the Terminal-Icons module on every run; it asks first (default No) and never in CI mode. +- `setup.ps1` loads its shared helpers from the verified profile, so setup and the profile run the same code; an install record (`installed.json`) is written for `psp-doctor` and the update check. +- `user-settings.json` and Windows Terminal's `settings.json` are read with comment and trailing-comma support on both editions; a `user-settings.json` that does not parse is never overwritten, and the previous file is kept as `user-settings.json.bak`. +- `Clear-ProfileCache` keeps state files (`telemetry.owned`, `wt-changes.json`, `installed.json`) and the resized background images Windows Terminal points at; the telemetry marker is removed only after the variable is. +- Windows Terminal values that an earlier profile version wrote are recorded against `settings.json.psp-original` (or the oldest backup), so uninstall restores what was there before the first install. `settings.json.psp-original` is kept after uninstall unless `-RemoveUserData`. A `user-settings.json` that does not parse makes `setup.ps1`, `Update-Profile` and `Uninstall-Profile` leave `settings.json` alone. +- `profile.original.ps1` is never a copy of this profile: setup and `setprofile.ps1` save the user's own profile (the oldest backup that is not this profile when the current one already is), and `Uninstall-Profile` does not restore a copy of this profile. +- `setup.ps1` run as a script block removes the variables it created in the caller's session; after a failed install it no longer offers to restart the terminal; the quick-start preset uses an editor that is already installed; `-LocalRepo` prints the bundle hash of the local files. +- Load-time temporaries are named `_psp*` and removed after load, so variables that `profile_user.ps1` or plugins define are no longer removed. +- Command guards: `killport` and `Stop-ListeningPort` refuse core Windows processes; `Remove-LockedItem` lists the lockers and confirms each process (`-Tree` is opt-in); `Clear-Cache` refuses a `TEMP` that does not look like a temp folder and asks first; `pkill` refuses match-everything patterns and asks before wildcard matches; `dstop` and `dprune` ask first; `extract` refuses to overwrite without `-Force`; `trash` reports cancelled or permanent deletes. +- `sed` keeps the file's encoding and byte order mark; `Copy-SshKey` never glues a key onto an unterminated last line and skips a key that is already present; `wifipass` and `Get-WslDistro` work with any display language; `Find-FileLocker` checks the files inside a folder. +- `NO_COLOR` turns off colored output; `Show-Help` and `psp-doctor` show the version, and `psp-doctor` shows where the install came from; a hung oh-my-posh prompt falls back to the plain prompt after `PromptTimeoutMs` (5 s). +- Startup: tool probes no longer trigger module discovery, the kubeconfig is re-read only when it changes, and load-time variables no longer leak into the session. + +### Fixed + +- Windows Terminal JSONC handling no longer breaks valid files with escaped quotes or `//` inside strings, and handles block comments and trailing commas. +- `user-settings.json` and other UTF-8 files are read as UTF-8 on Windows PowerShell 5.1. +- User keybindings from `user-settings.json` are appended to the shipped ones as one flat list. +- The wizard's quick-start preset now selects the CascadiaCode Nerd Font; before, its lookup never matched and no font was chosen. +- Windows Terminal: single-element arrays are recorded as arrays (and reverted), `-NoLogo` is recorded for pre-2020 files with a bare `profiles` list, an action the user bound to more keys is kept on uninstall, and `-HardResetWindowsTerminal` drops the change record of the file it deletes. +- `Update-Profile -InstallMissingTools` installs missing tools when the profile is already up to date, and its re-run hint keeps the switch. +- Trust changes merge into `user-settings.json` as it is on disk, so entries saved by another session are kept. +- `Uninstall-Profile -RemoveFonts` finds the font named in `terminal-config.json` (it was read after the cache was removed); a failed restore of `profile.original.ps1` keeps the file. +- An oh-my-posh render that times out is stopped with its child processes, and the fallback prompt no longer runs the prompt hooks twice. +- `Set-PspInstallState` drops values that are passed empty (a local install no longer keeps an old commit); internal writes never prompt or honor `-WhatIf` a second time. +- The wizard treats `[` and `*` in a reply literally instead of aborting; `nscan` names the target in its prompt; `extract` accepts paths on PowerShell drives; `Clear-Cache` accepts Remote Desktop temp folders. + +### Tests and CI + +- New `tests/unit.ps1` regression suite (Windows PowerShell 5.1 and PowerShell 7), `tests/smoke.ps1` load-time budget, and `tests/lib/Sandbox.ps1` shared sandbox helpers. Every suite redirects `LOCALAPPDATA`, `TEMP` and `$PROFILE`, blocks machine-changing commands (winget also when it is looked up as an application: a logging `winget.cmd` comes first on `PATH`), keeps font removal inside the sandbox, and refuses to run elevated without `-AllowElevated`. `tests/rawhunt.ps1` runs only with `-AllowSideEffects`. +- `tests/test.ps1` never installs modules (PSScriptAnalyzer is skipped when missing), leaves the clipboard alone outside CI, and skips the install check on a machine without an install. +- `tests/ci-functional.ps1` mocks third-party web services in pull-request CI (`-LiveNetwork` for the weekly run), soft-fails only on transport exceptions, never uploads to the public paste, and requires at least 60% of exports to execute. +- CI uses PSScriptAnalyzer 1.25.0 with `PSScriptAnalyzerSettings.psd1`, a pinned `windows-2025` image, `persist-credentials: false` and job timeouts, and tests the shipped functions instead of copies. New `release.yml` and `scheduled.yml` workflows. +- New `CHANGELOG.md`, `AGENTS.md`, `CODE_OF_CONDUCT.md`, `.editorconfig` and `.mailmap`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4ec5bca --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,19 @@ +# Code of Conduct + +This project follows the spirit of the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/), version 2.1. + +## Our standards + +Everyone who takes part in issues, pull requests, reviews and other project spaces is expected to: + +- be respectful and assume good intent, including when disagreeing about technical choices; +- give and accept constructive feedback, and focus on what is best for the project and its users; +- welcome people of every background and level of experience. + +Not acceptable: harassment, insults or personal attacks, sexualized language or imagery, publishing someone else's private information without permission, and other conduct that would reasonably be considered inappropriate in a professional setting. + +## Scope and enforcement + +This applies in all project spaces and when someone represents the project elsewhere. The maintainer may edit or remove comments, commits and other contributions that break these standards, and may block people who repeatedly or seriously do so. + +Report problems privately to the maintainer through [GitHub](https://github.com/26zl) or GitHub's "Report content" option. Reports are handled confidentially. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 766aa71..5fca073 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to PowerShellPerfect -Thanks for your interest in contributing! This guide covers the dev setup, coding conventions, and PR process. +Thanks for your interest in contributing! This guide covers the dev setup, coding conventions, tests, and the PR and release process. Please also read the [Code of Conduct](CODE_OF_CONDUCT.md). ## Dev Environment Setup @@ -11,12 +11,14 @@ Thanks for your interest in contributing! This guide covers the dev setup, codin cd PowerShellPerfect ``` -2. Copy the profile to your PowerShell profile directories: +2. Copy the profile to your PowerShell profile directories (`-WhatIf` shows what it would write): ```powershell .\setprofile.ps1 ``` + Or install this checkout with its configs and Windows Terminal settings but without tools: `pwsh -NoProfile -File tests/locallab.ps1 -Install`. + 3. Restart your terminal to load the profile. ## Coding Conventions @@ -28,80 +30,94 @@ All code must work under both PowerShell 5.1 and 7+. Key differences: - `Remove-Alias` only exists in PS6+; PS5 uses `Remove-Item Alias:\` - `$PSStyle` only exists in PS7.2+ - `PredictionSource`/`PredictionViewStyle` are PowerShell Core-only PSReadLine options +- `ConvertFrom-Json` on PS5 rejects comments and trailing commas; read JSON through `Read-JsonFile` / `ConvertFrom-JsonC` +- A function that returns a list with `return , $list` hands back one array object: wrap the call in parentheses, not `@()`, before piping or counting it (`(Get-PspArray $x 'k').Count`) - Guard version-specific code with `$PSVersionTable.PSEdition` or `$PSVersionTable.PSVersion.Major` ### Source File Rules - **No em dashes** (U+2014) or non-ASCII characters - CI PS5 parse fails on them -- **No BOM**: Never use `Set-Content -Encoding UTF8`. Use `[System.IO.File]::WriteAllText()` with `[System.Text.UTF8Encoding]::new($false)` +- **No BOM**: Never use `Set-Content -Encoding UTF8`. Use `Write-Utf8FileAtomic` (or `[System.IO.File]::WriteAllText()` with `[System.Text.UTF8Encoding]::new($false)`) +- Read text files with `Get-Utf8FileText` and pass paths with `-LiteralPath` +- Use `Write-PspHost` instead of `Write-Host` for colored output, so `NO_COLOR` is honored - Avoid `` and `|` pipes inside double-quoted strings - use single quotes or `-f` format operator - Use approved PowerShell verbs for function names (e.g., `Copy-SshKey` with alias `ssh-copy-key`) +- Give public commands comment-based help (`.SYNOPSIS`, and `.EXAMPLE` for anything with options) - Keep implementation comments to one sentence about current behavior, not change history +- Keep load-time state in `$script:PSP`; functions should not read load-time variables such as `$cacheDir` (call `Get-PspCacheDir`) +- Name load-time temporaries `$_pspSomething`: the end of the profile removes every `_psp*` variable, and only those, so names that `profile_user.ps1` or plugins define survive + +### Code Shared With setup.ps1 + +`setup.ps1` loads most of its helpers (JSON handling, the Windows Terminal change recorder, cache paths) from the profile **after** the downloaded bundle has been verified, so both always run the same code. The five helpers it needs before that point (`Write-PspHost`, `Invoke-DownloadWithRetry`, `Get-CombinedSha256`, `Resolve-PspCommitSha`, `Get-PspReleaseChecksums`) are copies: change both copies together. CI fails when a copy differs from the profile or when a name in `$script:SharedFunctionNames` is missing from the profile. ### Adding a New Tool 1. Add an entry to the `$script:ProfileTools` array in `Microsoft.PowerShell_profile.ps1` with `Name`, `Id` (winget), `Cmd`, `Cache`, and `VerCmd` 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`) +3. Pin anything downloaded at runtime (a version, and a SHA-256 where upstream publishes one) and add it to the pin check in `.github/workflows/scheduled.yml` ### 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`). +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`). Commands that delete, kill or change system settings need `SupportsShouldProcess` (and `ConfirmImpact = 'High'` when the effect is hard to undo). 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`. +3. Add an `Invoke-CommandProbe` entry in `tests/ci-functional.ps1` and a `T` entry in `tests/test.ps1` - either executing real code (inside the sandbox; use `-WhatIf` or stubs for anything destructive) or with a skip reason for interactive/tool-dependent commands. Every export must be covered, and at least 60% of them must actually execute, or the coverage audit fails. Commands that call a third-party web service need a mock in `Install-NetworkMocks` in `tests/ci-functional.ps1` (pull-request CI never calls those services). +4. If the command has nested internal helpers (e.g. `Write-JournalLine` inside `journal`), add them to `$internalOnly` in the coverage audits so they are not flagged as missing public commands. Helpers named `*-Psp*` and `*-Wt*` are treated as internal automatically. +5. Update the appropriate section in `Show-Help` and `README.md`, and list user-visible data flows in `SECURITY.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: - -```powershell -Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.24.0 -Force -Scope CurrentUser - -Invoke-ScriptAnalyzer -Path . -Recurse -ExcludeRule PSAvoidUsingWriteHost, PSAvoidUsingWMICmdlet, PSUseShouldProcessForStateChangingFunctions, PSUseBOMForUnicodeEncodedFile, PSReviewUnusedParameter, PSUseSingularNouns -``` +## Tests -### Smoke Test +Run the suites from a **normal (non-elevated)** shell. They redirect `LOCALAPPDATA`, `TEMP` and `$PROFILE` into disposable sandboxes and block commands that would change the machine, and they refuse to run elevated unless you pass `-AllowElevated` (meant for CI runners and disposable VMs). ```powershell -$env:CI = 'true'; . .\Microsoft.PowerShell_profile.ps1 +pwsh -NoProfile -File tests/unit.ps1 # regression tests (also run them with: powershell -NoProfile -File tests/unit.ps1) +pwsh -NoProfile -File tests/lint.ps1 # PSScriptAnalyzer 1.25.0 with PSScriptAnalyzerSettings.psd1 +pwsh -NoProfile -File tests/smoke.ps1 # sandboxed load with a load-time budget +pwsh -NoProfile -File tests/test.ps1 # the full local gate (includes the above) +pwsh -NoProfile -File tests/ci-functional.ps1 # what the CI functional job runs (third-party endpoints mocked) ``` -(Non-interactive mode is triggered by `$env:CI` or `$env:AI_AGENT`. Known agent env vars are normalized to `$env:AI_AGENT`.) +`tests/rawhunt.ps1 -AllowSideEffects` is the one harness that is not sandboxed: it uses your real profile settings, live services, the clipboard and winget, and uploads a probe file to a public paste. -### PS5 Parse Check +**Bug fixes need a regression test.** Add a case to `tests/unit.ps1` that fails without the fix. The helpers in `tests/lib/Sandbox.ps1` (`New-PspSandbox`, `Enter-PspSandbox`, `$PspPostLoadStubs`, `Get-PspFunctionSource`) give each case a disposable environment. Inside a sandbox, `winget` is a throwing function and, for code that looks it up as an application, a `winget.cmd` first on `PATH` that fails and logs each call (`Get-PspSandboxWingetCalls`); replace it with a recording function in the case when the test needs winget to succeed. -CI checks all `.ps1` files recursively. To match locally: +### Manual Checks Before a Release -```powershell -Get-ChildItem -Filter *.ps1 -Recurse | ForEach-Object { powershell -NoProfile -Command "`$t = `$null; `$e = `$null; [void][System.Management.Automation.Language.Parser]::ParseFile('$($_.FullName)', [ref]`$t, [ref]`$e); if (`$e.Count -gt 0) { `$e; exit 1 }" } -``` +CI runs on Windows Server images with an English display language. Before tagging a release, check on a Windows 10 and a Windows 11 client (a VM is fine), in both Windows PowerShell 5.1 and PowerShell 7: the README install command, the wizard, `Update-Profile -Ref `, `Uninstall-Profile` (with Windows Terminal Stable and Preview installed), and `wifipass` / `Get-WslDistro` under a non-English display language. ## Pull Request Process 1. Fork the repo and create a feature branch 2. Make your changes following the conventions above -3. Run PSScriptAnalyzer locally and fix any warnings/errors +3. Run `tests/lint.ps1` and `tests/unit.ps1` locally and fix any warnings/errors 4. Test on both PS5.1 and PS7+ if possible -5. Open a PR - the template will guide you through the checklist -6. CI must pass before merge +5. Add an entry under **Unreleased** in `CHANGELOG.md` for anything users will notice +6. Open a PR - the template will guide you through the checklist +7. All three CI jobs must pass before merge. The repository ruleset enforces `lint` and `install-flow` as required checks; `functional` must pass as well ## CI Checks -CI runs on push/PR to `main` with three jobs: +CI runs on push/PR to `main` with three jobs (on a pinned `windows-2025` image): + +- **lint**: PSScriptAnalyzer (`tests/lint.ps1`), sandboxed load on PS7 and PS5.1 with a load-time budget (`tests/smoke.ps1`), PS5 parse, hardcoded-path check, non-ASCII/BOM/secrets checks +- **install-flow**: JSON config validation, schema and pin checks, curated-data validation (schemes + fonts), `setup.ps1` structure (copies match the profile, verification before changes), `tests/unit.ps1` on PS7 and PS5.1 +- **functional**: `tests/ci-functional.ps1`: full install flow, sandbox install/execute/uninstall, coverage audit + +`scheduled.yml` runs every Monday: the functional suite against the real web services, and a check that every pinned download still matches its hash. CI also fails on hardcoded user paths and embedded secrets. -- **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, curated-data validation (schemes + fonts), required function checks (`Test-InternetConnection`, `Install-NerdFonts`, `Install-OhMyPoshTheme`, `Install-WingetPackage`, `Merge-JsonObject`, `Select-PreferredEditor`, `Invoke-DownloadWithRetry`, `Remove-SafeTempDirectory`, `Update-SessionPathFromRegistry`) -- **functional**: Runs `tests/ci-functional.ps1` (elevated): full install flow, sandbox install/execute/uninstall, and 100% command-probe coverage +## Releases -All three jobs must pass. CI also fails on hardcoded user paths and embedded secrets. +1. Update `Version` in `$script:PSP` in `Microsoft.PowerShell_profile.ps1` (SemVer). +2. Move the **Unreleased** entries in `CHANGELOG.md` under a new `## [X.Y.Z] - YYYY-MM-DD` heading. +3. Merge to `main`, then tag that commit `vX.Y.Z` and push the tag. `release.yml` checks that the tag matches `Version`, publishes the committed files with `SHA256SUMS.txt` and a build provenance attestation, and uses the CHANGELOG section as release notes. +4. Update the version in the README install command if it names a specific release. diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index 6be619a..8e7818a 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -1,7 +1,7 @@ ### PowerShell Profile (26zl) ### https://github.com/26zl/PowerShellPerfect -$profileStopwatch = [System.Diagnostics.Stopwatch]::StartNew() +$_pspStopwatch = [System.Diagnostics.Stopwatch]::StartNew() # Normalize known agent/AI env vars to AI_AGENT. if (-not [bool]$env:AI_AGENT -and ([bool]$env:AGENT_ID -or [bool]$env:CLAUDE_CODE -or [bool]$env:CODEX -or [bool]$env:CODEX_AGENT)) { @@ -21,25 +21,23 @@ 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" +# Return the profile cache directory, resolved from LOCALAPPDATA at call time so redirected environments are honored. +function Get-PspCacheDir { + $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { [System.IO.Path]::GetTempPath() } + Join-Path $base 'PowerShellProfile' +} -# Store caches outside Documents and use the temp directory when LOCALAPPDATA is unavailable. -$localAppData = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { [System.IO.Path]::GetTempPath() } -$cacheDir = Join-Path $localAppData "PowerShellProfile" -if (-not (Test-Path $cacheDir)) { +# Kept for scripts that read it; profile functions call Get-PspCacheDir instead. +$cacheDir = Get-PspCacheDir +if (-not (Test-Path -LiteralPath $cacheDir)) { # Cache creation is best-effort. 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. -$_q = [char]34 -$jsoncCommentPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*`$" - # Detect administrator status safely across PowerShell editions and platforms. -$isWindowsOS = (-not (Test-Path Variable:\IsWindows)) -or $IsWindows -if ($isWindowsOS) { +$_pspIsWindowsOS = (-not (Test-Path Variable:\IsWindows)) -or $IsWindows +if ($_pspIsWindowsOS) { try { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { $isAdmin = $false } } @@ -60,6 +58,15 @@ $script:ProfileTools = @( # Expose hooks, feature toggles, command metadata, and shared state through $PSP. $script:PSP = @{ + # Semantic version of the profile; release tags (vX.Y.Z) must match it. + Version = '1.0.0' + RepoOwner = '26zl' + RepoName = 'PowerShellPerfect' + IsInteractive = $isInteractive + IsAdmin = $isAdmin + AdminSuffix = $(if ($isAdmin) { ' [ADMIN]' } else { '' }) + # Milliseconds to wait for oh-my-posh before falling back to the plain prompt. + PromptTimeoutMs = 5000 Hooks = @{ OnProfileLoad = [System.Collections.Generic.List[scriptblock]]::new() PrePrompt = [System.Collections.Generic.List[scriptblock]]::new() @@ -82,6 +89,8 @@ $script:PSP = @{ # Return the collapsed prompt text used by the customizable transient prompt. TransientPrompt = { "$ " } TrustedDirs = [System.Collections.Generic.List[string]]::new() + # SHA-256 of the .psprc.ps1 approved for each trusted directory ('' when none existed at approval). + TrustedRcHashes = @{} LastPwd = $null # Store the most recent directories added by Invoke-PromptStage. PwdHistory = [System.Collections.Generic.List[string]]::new() @@ -108,6 +117,13 @@ function Invoke-ProfileHook { # Register a scriptblock to run on a profile lifecycle event (OnProfileLoad | PrePrompt | OnCd). function Register-ProfileHook { + <# + .SYNOPSIS + Hook lifecycle events. + .EXAMPLE + Register-ProfileHook -EventName OnCd -Action { Write-Host $PWD } + Run code after every directory change. + #> [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('OnProfileLoad', 'PrePrompt', 'OnCd')][string]$EventName, @@ -118,6 +134,10 @@ function Register-ProfileHook { # Add a custom help section from a plugin or profile_user.ps1. function Register-HelpSection { + <# + .SYNOPSIS + Add section to Show-Help. + #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Title, @@ -128,6 +148,10 @@ function Register-HelpSection { # Add a command to the discovery registry consumed by Get-ProfileCommand. function Register-ProfileCommand { + <# + .SYNOPSIS + Register a command for discovery. + #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, @@ -174,17 +198,17 @@ function Invoke-DownloadWithRetry { ) for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { - Remove-Item $OutFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $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 + if (-not (Test-Path -LiteralPath $OutFile) -or (Get-Item -LiteralPath $OutFile).Length -eq 0) { + Remove-Item -LiteralPath $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 + Write-PspHost " Download failed (attempt $attempt/$MaxAttempts): $_ Retrying in ${BackoffSec}s..." -ForegroundColor Yellow Start-Sleep -Seconds $BackoffSec } else { @@ -194,24 +218,61 @@ function Invoke-DownloadWithRetry { } } -# Quote Start-Process arguments that contain whitespace or quotes. +# Quote arguments for a single native command line using the CommandLineToArgvW rules (quotes and trailing backslashes). function ConvertTo-NativeArgumentLine { param([Parameter(Mandatory)][AllowEmptyCollection()][string[]]$ArgumentList) ($ArgumentList | ForEach-Object { - if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } + if ($_ -eq '') { '""' } + elseif ($_ -notmatch '[\s"]') { $_ } + else { + $s = $_ -replace '(\\*)"', '$1$1\"' + $s = $s -replace '(\\+)$', '$1$1' + '"' + $s + '"' + } }) -join ' ' } -# Read text as UTF-8 with BOM detection across PowerShell editions. +# Write host output, dropping colors when the NO_COLOR environment variable is set (https://no-color.org). +function Write-PspHost { + [CmdletBinding()] + param( + [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromRemainingArguments = $true)][object[]]$Object, + [System.ConsoleColor]$ForegroundColor, + [System.ConsoleColor]$BackgroundColor, + [switch]$NoNewline, + [object]$Separator + ) + process { + $splat = @{} + if ($null -ne $Object) { $splat.Object = $(if ($Object.Count -eq 1) { $Object[0] } else { $Object }) } + if ($NoNewline) { $splat.NoNewline = $true } + if ($PSBoundParameters.ContainsKey('Separator')) { $splat.Separator = $Separator } + if ([string]::IsNullOrEmpty($env:NO_COLOR)) { + if ($PSBoundParameters.ContainsKey('ForegroundColor')) { $splat.ForegroundColor = $ForegroundColor } + if ($PSBoundParameters.ContainsKey('BackgroundColor')) { $splat.BackgroundColor = $BackgroundColor } + } + Write-Host @splat + } +} + +# Read text as UTF-8 with BOM detection across PowerShell editions; relative paths resolve against the current location. function Get-Utf8FileText { param([Parameter(Mandatory)][string]$Path) - [System.IO.File]::ReadAllText($Path) + [System.IO.File]::ReadAllText($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)) } # Write UTF-8 without BOM through a sibling temp file before replacing the target; a symlink is written in place so the link survives. function Write-Utf8FileAtomic { - param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Content) + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][AllowEmptyString()][string]$Content, + # Copy the current file here first; the write is abandoned if the copy fails. + [string]$BackupPath + ) + $Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $existing = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + # Callers gate the write with ShouldProcess; the steps below must not prompt again or honor -WhatIf on their own. + if ($BackupPath -and $existing) { Copy-Item -LiteralPath $Path -Destination $BackupPath -Force -ErrorAction Stop -Confirm:$false -WhatIf:$false } if ($existing -and $existing.LinkType) { [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) return @@ -221,25 +282,262 @@ function Write-Utf8FileAtomic { $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 + Move-Item -LiteralPath $tmp -Destination $Path -Force -ErrorAction Stop -Confirm:$false -WhatIf:$false } catch { - if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue -Confirm:$false -WhatIf:$false } throw } } -# Return the upstream main SHA from GitHub or $null on failure. -function Get-LatestMainCommitSha { +# Parse JSON that may contain // or /* */ comments and trailing commas (Windows Terminal JSONC); string contents are never altered. +function ConvertFrom-JsonC { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) + $keepStrings = [System.Text.RegularExpressions.MatchEvaluator] { + param($m) + if ($m.Value[0] -eq [char]34) { $m.Value } elseif ($m.Value.StartsWith('/*')) { ' ' } else { '' } + } + $noComments = [regex]::Replace($Text, '"(?:[^"\\]|\\.)*"|//[^\r\n]*|/\*[\s\S]*?\*/', $keepStrings) + $clean = [regex]::Replace($noComments, '"(?:[^"\\]|\\.)*"|,(?=\s*[}\]])', $keepStrings) + if ([string]::IsNullOrWhiteSpace($clean)) { return $null } + $parsed = $clean | ConvertFrom-Json -ErrorAction Stop + if ($parsed -is [System.Collections.IList]) { return , $parsed } + return $parsed +} + +# Read a UTF-8 (BOM-aware) JSON or JSONC file; returns $null for a missing or empty file and throws on invalid content. +function Read-JsonFile { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $raw = Get-Utf8FileText $Path + if ([string]::IsNullOrWhiteSpace($raw)) { return $null } + $parsed = ConvertFrom-JsonC -Text $raw + if ($parsed -is [System.Collections.IList]) { return , $parsed } + return $parsed +} + +# Return a property of an object or dictionary, keeping list values intact ($null when absent). +function Get-PspProp { + param([AllowNull()][object]$Object, [Parameter(Mandatory)][string]$Name) + if ($null -eq $Object) { return $null } + $value = $null + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.Contains($Name)) { $value = $Object[$Name] } + } + else { + $p = $Object.PSObject.Properties[$Name] + if ($p) { $value = $p.Value } + } + if ($value -is [System.Collections.IList]) { return , $value } + return $value +} + +# Return a property that holds a list as an object[] without null entries (empty when absent). +function Get-PspArray { + param([AllowNull()][object]$Object, [Parameter(Mandatory)][string]$Name) + $value = Get-PspProp $Object $Name + return , [object[]]@($value | Where-Object { $null -ne $_ }) +} + +# Return a nested property addressed by a dotted path such as 'profiles.defaults' ($null when any part is missing). +function Get-PspPath { + param([AllowNull()][object]$Object, [Parameter(Mandatory)][string]$Path) + $node = $Object + foreach ($part in $Path.Split('.')) { + if ($null -eq $node) { return $null } + $node = Get-PspProp $node $part + } + if ($node -is [System.Collections.IList]) { return , $node } + return $node +} + +# Return $true when an object or dictionary has the named property. +function Test-PspProp { + param([AllowNull()][object]$Object, [Parameter(Mandatory)][string]$Name) + if ($null -eq $Object) { return $false } + if ($Object -is [System.Collections.IDictionary]) { return $Object.Contains($Name) } + [bool]$Object.PSObject.Properties[$Name] +} + +# Set or add a property on a PSCustomObject or dictionary. +function Set-PspProp { + param([Parameter(Mandatory)][object]$Object, [Parameter(Mandatory)][string]$Name, [AllowNull()][object]$Value) + if ($Object -is [System.Collections.IDictionary]) { $Object[$Name] = $Value; return } + $Object | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force +} + +# Remove a property from a PSCustomObject or dictionary if present. +function Remove-PspProp { + param([AllowNull()][object]$Object, [Parameter(Mandatory)][string]$Name) + if ($null -eq $Object) { return } + if ($Object -is [System.Collections.IDictionary]) { $Object.Remove($Name); return } + if ($Object.PSObject.Properties[$Name]) { $Object.PSObject.Properties.Remove($Name) } +} + +# Return a deep copy of parsed JSON data. +function Copy-PspObject { + param([AllowNull()][object]$Value) + if ($null -eq $Value) { return $null } + $copy = ConvertTo-Json -InputObject $Value -Depth 50 -Compress | ConvertFrom-Json + if ($copy -is [System.Collections.IList]) { return , $copy } + return $copy +} + +# Serialize parsed JSON data with object keys sorted recursively, so equal settings compare equal across editions. +function ConvertTo-PspCanonicalJson { + param([AllowNull()][object]$Value) + function ConvertTo-PspSortedNode { + param([AllowNull()][object]$Node) + if ($null -eq $Node) { return $null } + if ($Node -is [string]) { return $Node } + if ($Node -is [System.Collections.IDictionary]) { + $sorted = [ordered]@{} + foreach ($k in ($Node.Keys | Sort-Object)) { $sorted[[string]$k] = ConvertTo-PspSortedNode $Node[$k] } + return $sorted + } + if ($Node -is [System.Management.Automation.PSCustomObject]) { + $sorted = [ordered]@{} + foreach ($p in ($Node.PSObject.Properties | Sort-Object Name)) { $sorted[$p.Name] = ConvertTo-PspSortedNode $p.Value } + return $sorted + } + if ($Node -is [System.Collections.IEnumerable]) { + return , @(foreach ($item in $Node) { ConvertTo-PspSortedNode $item }) + } + return $Node + } + ConvertTo-Json -InputObject (ConvertTo-PspSortedNode $Value) -Depth 50 -Compress +} + +# Return $true when two parsed JSON values are equal regardless of key order. +function Test-PspJsonEqual { + param([AllowNull()][object]$Left, [AllowNull()][object]$Right) + (ConvertTo-PspCanonicalJson $Left) -ceq (ConvertTo-PspCanonicalJson $Right) +} + +# Return the combined SHA-256 that pins a whole install bundle (profile, theme.json, terminal-config.json). +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() } +} + +# Resolve a branch, tag or commit to its full commit SHA through the GitHub API ($null when offline or rate-limited). +function Resolve-PspCommitSha { + param( + [Parameter(Mandatory)][string]$Owner, + [Parameter(Mandatory)][string]$Repo, + [Parameter(Mandatory)][string]$Ref, + [int]$TimeoutSec = 10 + ) + if ($Ref -match '^[0-9a-fA-F]{40}$') { return $Ref.ToLowerInvariant() } 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 } + $uri = 'https://api.github.com/repos/{0}/{1}/commits/{2}' -f $Owner, $Repo, [Uri]::EscapeDataString($Ref) + $headers = @{ 'User-Agent' = 'PowerShellPerfect'; Accept = 'application/vnd.github.sha' } + if ($env:GITHUB_TOKEN) { $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" } + $sha = [string](Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop) + if ($sha.Trim() -match '^[0-9a-f]{40}$') { return $sha.Trim() } } catch { $null = $_ } return $null } +# Return the SHA-256 values a release publishes in SHA256SUMS.txt, keyed by file name ($null when the ref is not a release tag or the file is unavailable). +function Get-PspReleaseChecksums { + param( + [Parameter(Mandatory)][string]$Owner, + [Parameter(Mandatory)][string]$Repo, + [Parameter(Mandatory)][string]$Tag, + [int]$TimeoutSec = 15 + ) + if ($Tag -notmatch '^v\d+\.\d+\.\d+$') { return $null } + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ('psp-sums-' + [System.IO.Path]::GetRandomFileName()) + try { + $uri = 'https://github.com/{0}/{1}/releases/download/{2}/SHA256SUMS.txt' -f $Owner, $Repo, $Tag + Invoke-RestMethod -Uri $uri -OutFile $tmp -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + $sums = @{} + foreach ($line in [System.IO.File]::ReadAllLines($tmp)) { + if ($line -match '^([0-9A-Fa-f]{64})\s+\*?(\S+)\s*$') { $sums[$Matches[2]] = $Matches[1].ToUpperInvariant() } + } + if ($sums.Count -eq 0) { return $null } + return $sums + } + catch { return $null } + finally { [System.IO.File]::Delete($tmp) } +} + +# Return the raw.githubusercontent.com URL of a repository file at a branch, tag or commit. +function Get-PspRawUrl { + param([Parameter(Mandatory)][string]$Ref, [Parameter(Mandatory)][string]$Path) + 'https://raw.githubusercontent.com/{0}/{1}/{2}/{3}' -f $script:PSP.RepoOwner, $script:PSP.RepoName, $Ref, $Path +} + +# Return the upstream main SHA from GitHub or $null on failure. +function Get-LatestMainCommitSha { + Resolve-PspCommitSha -Owner $script:PSP.RepoOwner -Repo $script:PSP.RepoName -Ref 'main' -TimeoutSec 3 +} + +# Return the pinned versions of third-party components the profile and setup.ps1 install at runtime. +function Get-PspComponentPins { + @{ + PSFzf = '2.7.12' + HttpServer = '14.1.1' + } +} + +# Read the install record written by setup.ps1 and Update-Profile (version, source ref, commit, bundle hash). +function Get-PspInstallState { + param([string]$CacheDir = (Get-PspCacheDir)) + try { Read-JsonFile (Join-Path $CacheDir 'installed.json') } catch { $null } +} + +# Return $true when a file is a copy of this profile (its header line), so it is never kept or restored as the user's own profile. +function Test-PspProfileFile { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } + try { $head = @(Get-Content -LiteralPath $Path -TotalCount 3 -ErrorAction Stop) } catch { return $false } + [bool]@($head | Where-Object { $_ -match '^### PowerShell Profile \(26zl\)' }).Count +} + +# Return the file that holds the user's own profile from before the first install: the current profile unless it is a +# copy of this one, otherwise the oldest oldprofile*.ps1 backup that is not ($null when there is none). +function Get-PspOriginalProfileSource { + param([Parameter(Mandatory)][string]$Directory) + $current = Join-Path $Directory 'Microsoft.PowerShell_profile.ps1' + if ((Test-Path -LiteralPath $current -PathType Leaf) -and -not (Test-PspProfileFile -Path $current)) { return $current } + $backup = Get-ChildItem -LiteralPath $Directory -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime | Where-Object { -not (Test-PspProfileFile -Path $_.FullName) } | Select-Object -First 1 + if ($backup) { return $backup.FullName } + return $null +} + +# Write the install record; unknown values are kept from the previous record. +function Set-PspInstallState { + param( + [Parameter(Mandatory)][string]$CacheDir, + [string]$Version, + [string]$Ref, + [string]$Commit, + [string]$BundleSha256, + [string]$Source + ) + $state = Get-PspInstallState -CacheDir $CacheDir + if ($null -eq $state) { $state = [PSCustomObject]@{} } + $values = [ordered]@{ version = @('Version', $Version); ref = @('Ref', $Ref); commit = @('Commit', $Commit); bundleSha256 = @('BundleSha256', $BundleSha256); source = @('Source', $Source) } + foreach ($key in @($values.Keys)) { + if ($values[$key][1]) { Set-PspProp $state $key $values[$key][1] } + # Passed but empty (for example a commit the GitHub API could not resolve): drop the previous value. + elseif ($PSBoundParameters.ContainsKey($values[$key][0])) { Remove-PspProp $state $key } + } + Set-PspProp $state 'updatedAt' (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') + if (-not (Test-Path -LiteralPath $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } + Write-Utf8FileAtomic -Path (Join-Path $CacheDir 'installed.json') -Content ($state | ConvertTo-Json -Depth 5) +} + # Get the full path to an external command function Get-ExternalCommandPath { param( @@ -323,6 +621,447 @@ function Merge-JsonObject { } } +# Load the manifest that records each Windows Terminal change the profile made (empty when missing or unreadable). +function Read-WtChangeManifest { + param([Parameter(Mandatory)][string]$ManifestPath) + $manifest = $null + try { $manifest = Read-JsonFile $ManifestPath } + catch { Write-Warning "Ignoring unreadable ${ManifestPath}: $($_.Exception.Message)" } + if ($null -eq $manifest) { $manifest = [PSCustomObject]@{ version = 1; files = [PSCustomObject]@{} } } + if (-not (Get-PspProp $manifest 'files')) { Set-PspProp $manifest 'files' ([PSCustomObject]@{}) } + return $manifest +} + +# Save the change manifest, deleting it once no settings file has recorded changes. +function Save-WtChangeManifest { + param([Parameter(Mandatory)][string]$ManifestPath, [Parameter(Mandatory)][object]$Manifest) + if (@($Manifest.files.PSObject.Properties).Count -eq 0) { + Remove-Item -LiteralPath $ManifestPath -Force -ErrorAction SilentlyContinue -Confirm:$false -WhatIf:$false + return + } + $dir = Split-Path -Parent $ManifestPath + if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force -Confirm:$false -WhatIf:$false | Out-Null } + Write-Utf8FileAtomic -Path $ManifestPath -Content ($Manifest | ConvertTo-Json -Depth 100) +} + +# Record the pre-install value of a setting once and the value the profile applies each time. +function Add-WtChangeRecord { + param( + [Parameter(Mandatory)][object]$Section, + [Parameter(Mandatory)][string]$Name, + [bool]$Existed, + [AllowNull()][object]$Original, + [AllowNull()][object]$Applied, + [switch]$AppliedRemoved + ) + $record = Get-PspProp $Section $Name + if (-not $record) { + $record = [PSCustomObject]@{ existed = $Existed; original = $Original; applied = $null; removed = $false } + Set-PspProp $Section $Name $record + } + $record.applied = $Applied + $record.removed = [bool]$AppliedRemoved +} + +# Set or remove one settings property and record it; object values (font) are merged so unmanaged keys survive. +function Set-WtRecordedValue { + param( + [Parameter(Mandatory)][object]$Container, + [Parameter(Mandatory)][string]$Name, + [AllowNull()][object]$Value, + [Parameter(Mandatory)][object]$Section, + [AllowNull()][object]$Baseline, + # Pre-install copy; used when the file already holds the value being written (an earlier profile version wrote it). + [AllowNull()][object]$LegacyBaseline, + [switch]$Remove + ) + $current = Get-PspProp $Container $Name + if (-not $Remove -and $Value -is [System.Management.Automation.PSCustomObject] -and $current -is [System.Management.Automation.PSCustomObject]) { + $merged = Copy-PspObject $current + foreach ($p in $Value.PSObject.Properties) { Set-PspProp $merged $p.Name $p.Value } + $Value = $merged + } + $reference = $Baseline + if (-not $Remove -and $null -ne $LegacyBaseline -and (Test-PspProp $Container $Name) -and (Test-PspJsonEqual $current $Value)) { $reference = $LegacyBaseline } + $baseHas = Test-PspProp $reference $Name + # Assigned in branches: an if expression would unroll array values such as ["window"]. + $original = $null + if ($baseHas) { $original = Get-PspProp $reference $Name } + $applied = $null + if (-not $Remove) { $applied = $Value } + Add-WtChangeRecord -Section $Section -Name $Name -Existed $baseHas -Original $original -Applied $applied -AppliedRemoved:$Remove + if ($Remove) { Remove-PspProp $Container $Name } else { Set-PspProp $Container $Name $Value } +} + +# Upsert a named entry (color scheme or theme) in a settings array and record it. +function Set-WtRecordedArrayItem { + param( + [Parameter(Mandatory)][object]$Root, + [Parameter(Mandatory)][string]$ArrayName, + [Parameter(Mandatory)][object]$Item, + [Parameter(Mandatory)][object]$Section, + [AllowNull()][object]$Baseline, + [AllowNull()][object]$LegacyBaseline + ) + $name = [string](Get-PspProp $Item 'name') + if (-not $name) { return } + $items = Get-PspArray $Root $ArrayName + $currentItem = $items | Where-Object { (Get-PspProp $_ 'name') -eq $name } | Select-Object -First 1 + $reference = if ($null -ne $LegacyBaseline -and $null -ne $currentItem -and (Test-PspJsonEqual $currentItem $Item)) { $LegacyBaseline } else { $Baseline } + $baseItem = (Get-PspArray $reference $ArrayName) | Where-Object { (Get-PspProp $_ 'name') -eq $name } | Select-Object -First 1 + Add-WtChangeRecord -Section $Section -Name $name -Existed ($null -ne $baseItem) -Original $baseItem -Applied $Item + Set-PspProp $Root $ArrayName ([object[]](@($items | Where-Object { (Get-PspProp $_ 'name') -ne $name }) + $Item)) +} + +# Bind keys to a command (new id-based or old inline actions format) and record what the binding replaced. +function Set-WtRecordedKeybinding { + param( + [Parameter(Mandatory)][object]$Root, + [Parameter(Mandatory)][object]$Binding, + [Parameter(Mandatory)][object]$Section, + [AllowNull()][object]$Baseline, + [AllowNull()][object]$LegacyBaseline + ) + $keys = [string](Get-PspProp $Binding 'keys') + if ([string]::IsNullOrWhiteSpace($keys)) { return } + $command = Get-PspProp $Binding 'command' + $id = 'User.profile.' + ($keys -replace '[^a-zA-Z0-9]', '') + $newFormat = Test-PspProp $Root 'keybindings' + $record = Get-PspProp $Section $keys + if (-not $record) { + # Remember what the pre-install file bound to these keys so uninstall can put it back. + $source = if ($null -ne $Baseline) { $Baseline } else { $Root } + # The binding is already there when an earlier profile version set it; the pre-install copy shows what it replaced. + $alreadyBound = @((Get-PspArray $Root 'actions') | Where-Object { (Get-PspProp $_ 'id') -eq $id -or ((Get-PspProp $_ 'keys') -eq $keys -and (Test-PspJsonEqual (Get-PspProp $_ 'command') $command)) }).Count -gt 0 + if ($alreadyBound -and $null -ne $LegacyBaseline) { $source = $LegacyBaseline } + $removedBindings = @((Get-PspArray $source 'keybindings') | Where-Object { (Get-PspProp $_ 'keys') -eq $keys }) + if (Test-PspProp $source 'keybindings') { + $boundIds = @($removedBindings | ForEach-Object { Get-PspProp $_ 'id' }) + $removedActions = @((Get-PspArray $source 'actions') | Where-Object { $boundIds -contains (Get-PspProp $_ 'id') }) + } + else { + $removedActions = @((Get-PspArray $source 'actions') | Where-Object { (Get-PspProp $_ 'keys') -eq $keys }) + } + $record = [PSCustomObject]@{ format = 'old'; id = $id; appliedAction = $null; appliedBinding = $null; removedActions = [object[]]$removedActions; removedBindings = [object[]]$removedBindings } + Set-PspProp $Section $keys $record + } + $actions = Get-PspArray $Root 'actions' + if ($newFormat) { + $existingIds = @((Get-PspArray $Root 'keybindings') | Where-Object { (Get-PspProp $_ 'keys') -eq $keys } | ForEach-Object { Get-PspProp $_ 'id' }) + $actions = @($actions | Where-Object { ($existingIds -notcontains (Get-PspProp $_ 'id')) -and (Get-PspProp $_ 'id') -ne $id }) + $bindings = @((Get-PspArray $Root 'keybindings') | Where-Object { (Get-PspProp $_ 'keys') -ne $keys }) + $action = [PSCustomObject]@{ command = $command; id = $id } + $bindingEntry = [PSCustomObject]@{ id = $id; keys = $keys } + Set-PspProp $Root 'actions' ([object[]]($actions + $action)) + Set-PspProp $Root 'keybindings' ([object[]]($bindings + $bindingEntry)) + $record.format = 'new'; $record.appliedAction = $action; $record.appliedBinding = $bindingEntry + } + else { + $actions = @($actions | Where-Object { (Get-PspProp $_ 'keys') -ne $keys }) + $action = [PSCustomObject]@{ keys = $keys; command = $command } + Set-PspProp $Root 'actions' ([object[]]($actions + $action)) + $record.format = 'old'; $record.appliedAction = $action; $record.appliedBinding = $null + } +} + +# Return the profiles list of a settings object, including pre-2020 files that keep profiles as a bare list. +function Get-WtProfileList { + param([AllowNull()][object]$Settings) + $profilesNode = Get-PspProp $Settings 'profiles' + if ($profilesNode -is [System.Collections.IList]) { return , [object[]]@($profilesNode | Where-Object { $null -ne $_ }) } + return , (Get-PspArray $profilesNode 'list') +} + +# Launch PowerShell profiles in profiles.list with -NoLogo and record each changed command line. +function Set-WtRecordedNoLogo { + param([Parameter(Mandatory)][object]$Root, [Parameter(Mandatory)][object]$Section, [AllowNull()][object]$Baseline, [AllowNull()][object]$LegacyBaseline) + $baseList = Get-WtProfileList $Baseline + $legacyList = Get-WtProfileList $LegacyBaseline + foreach ($prof in (Get-WtProfileList $Root)) { + $cmd = [string](Get-PspProp $prof 'commandline') + $src = [string](Get-PspProp $prof 'source') + $profName = [string](Get-PspProp $prof 'name') + $isPwsh = $cmd -match 'pwsh' -or $src -match 'Windows\.Terminal\.PowerShellCore' + $isPS5 = $cmd -match 'powershell\.exe' -or $profName -match 'Windows PowerShell' + if (-not ($isPwsh -or $isPS5)) { continue } + $guid = [string](Get-PspProp $prof 'guid') + $recordKey = if ($guid) { "guid:$guid" } else { "name:$profName" } + $matchProfile = { param($candidate) if ($guid) { (Get-PspProp $candidate 'guid') -eq $guid } else { (Get-PspProp $candidate 'name') -eq $profName } } + $baseProf = $baseList | Where-Object { & $matchProfile $_ } | Select-Object -First 1 + if ($null -ne $baseProf) { + $baseHas = Test-PspProp $baseProf 'commandline' + $baseCmd = if ($baseHas) { [string](Get-PspProp $baseProf 'commandline') } else { $null } + } + else { + # A profile the baseline does not know (added since) keeps its current command line as the original. + $baseHas = [bool]$cmd + $baseCmd = if ($cmd) { $cmd } else { $null } + } + $newCmd = $null + if ($cmd -and $cmd -notmatch '-NoLogo' -and $cmd -notmatch '(?i)-(Command|File|EncodedCommand)') { $newCmd = "$cmd -NoLogo" } + elseif (-not $cmd -and $src) { $newCmd = $(if ($isPwsh) { 'pwsh.exe -NoLogo' } else { 'powershell.exe -NoLogo' }) } + elseif ($cmd -match '-NoLogo' -and -not (Get-PspProp $Section $recordKey)) { + # Adopt a -NoLogo an earlier profile version added (the pre-install copy has the profile without it) so uninstall can remove it. + $legacyProf = $legacyList | Where-Object { & $matchProfile $_ } | Select-Object -First 1 + if ($null -ne $legacyProf) { + $legacyHas = Test-PspProp $legacyProf 'commandline' + $legacyCmd = if ($legacyHas) { [string](Get-PspProp $legacyProf 'commandline') } else { $null } + if ($legacyCmd -ne $cmd) { Add-WtChangeRecord -Section $Section -Name $recordKey -Existed $legacyHas -Original $legacyCmd -Applied $cmd } + } + elseif ($null -ne $baseProf -and $cmd -ne $baseCmd) { + Add-WtChangeRecord -Section $Section -Name $recordKey -Existed $baseHas -Original $baseCmd -Applied $cmd + } + continue + } + if (-not $newCmd) { continue } + Add-WtChangeRecord -Section $Section -Name $recordKey -Existed $baseHas -Original $baseCmd -Applied $newCmd + Set-PspProp $prof 'commandline' $newCmd + } +} + +# Apply profile-managed settings to one Windows Terminal settings.json and record every change in the manifest. +function Update-WindowsTerminalSettingsFile { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ManifestPath, + # profiles.defaults values to set (object values are merged key by key). + [AllowNull()][object]$Defaults, + # profiles.defaults keys to remove. + [string[]]$RemoveDefaults = @(), + # theme.json windowsTerminal section: colorScheme, cursorColor, scheme, theme, themeDefinition. + [AllowNull()][object]$WindowsTerminal, + # Keybindings as objects with keys and command. + [AllowNull()][object[]]$Keybindings, + # Append -NoLogo to PowerShell profiles in profiles.list. + [switch]$NoLogo, + [int]$KeepBackups = 5 + ) + $raw = Get-Utf8FileText $Path + $wt = if ([string]::IsNullOrWhiteSpace($raw)) { $null } else { ConvertFrom-JsonC -Text $raw } + if ($null -eq $wt) { $wt = [PSCustomObject]@{} } + $dir = Split-Path -Parent $Path + $leaf = Split-Path -Leaf $Path + $timestamped = @(Get-ChildItem -LiteralPath $dir -Filter "$leaf.*.bak" -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '\.\d{8}-\d{6}\.bak$' } | Sort-Object LastWriteTime) + $manifest = Read-WtChangeManifest -ManifestPath $ManifestPath + $key = $Path.ToLowerInvariant() + $entry = Get-PspProp $manifest.files $key + # Values first seen in this run are recorded against the file as it was before this write. A value the file + # already holds was probably written by an earlier profile version, so it is recorded against the pre-install copy + # (settings.json.psp-original, or the oldest timestamped backup left by versions without a change record). + $baseline = Copy-PspObject $wt + $originalCopy = "$Path.psp-original" + $legacyBaseline = $null + $legacySource = if (Test-Path -LiteralPath $originalCopy) { $originalCopy } elseif ($timestamped.Count -gt 0) { $timestamped[0].FullName } else { $null } + if ($legacySource) { + try { $legacyBaseline = ConvertFrom-JsonC -Text (Get-Utf8FileText $legacySource) } + catch { $null = $_ } + } + if (-not $entry) { + $entry = [PSCustomObject]@{ + defaults = [PSCustomObject]@{}; root = [PSCustomObject]@{}; schemes = [PSCustomObject]@{} + themes = [PSCustomObject]@{}; bindings = [PSCustomObject]@{}; commandlines = [PSCustomObject]@{} + } + Set-PspProp $manifest.files $key $entry + } + if (-not $PSCmdlet.ShouldProcess($Path, 'Update Windows Terminal settings')) { return $false } + + # Keep one durable copy of the file as it was before the profile first changed it. + if (-not (Test-Path -LiteralPath $originalCopy)) { + $source = if ($timestamped.Count -gt 0) { $timestamped[0].FullName } else { $Path } + Copy-Item -LiteralPath $source -Destination $originalCopy -ErrorAction Stop -Confirm:$false + } + # Rotating backup; a failed backup aborts the write. + Copy-Item -LiteralPath $Path -Destination ('{0}.{1}.bak' -f $Path, (Get-Date -Format 'yyyyMMdd-HHmmss')) -Force -ErrorAction Stop -Confirm:$false + Get-ChildItem -LiteralPath $dir -Filter "$leaf.*.bak" -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '\.\d{8}-\d{6}\.bak$' } | Sort-Object LastWriteTime -Descending | + Select-Object -Skip $KeepBackups | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue -Confirm:$false } + + $profilesNode = Get-PspProp $wt 'profiles' + if ($profilesNode -is [array]) { + # Pre-2020 files keep profiles as a bare list; Windows Terminal accepts the object form. + $profilesNode = [PSCustomObject]@{ defaults = [PSCustomObject]@{}; list = [object[]]$profilesNode } + Set-PspProp $wt 'profiles' $profilesNode + } + elseif ($null -eq $profilesNode) { + $profilesNode = [PSCustomObject]@{} + Set-PspProp $wt 'profiles' $profilesNode + } + $defaultsNode = Get-PspProp $profilesNode 'defaults' + if ($null -eq $defaultsNode) { + $defaultsNode = [PSCustomObject]@{} + Set-PspProp $profilesNode 'defaults' $defaultsNode + } + $baseDefaults = Get-PspPath $baseline 'profiles.defaults' + $legacyDefaults = $null + if ($null -ne $legacyBaseline) { + $legacyDefaults = Get-PspPath $legacyBaseline 'profiles.defaults' + # A pre-install file without defaults still counts: the profile added every default it holds. + if ($null -eq $legacyDefaults) { $legacyDefaults = [PSCustomObject]@{} } + } + # Arrays the profile adds to a file that had none are removed again on uninstall once they are empty. + $created = [System.Collections.Generic.List[string]]::new() + foreach ($name in (Get-PspArray $entry 'createdArrays')) { $created.Add([string]$name) } + $wantsArray = @{ + schemes = [bool]($WindowsTerminal -and (Get-PspProp $WindowsTerminal 'scheme')) + themes = [bool]($WindowsTerminal -and (Get-PspProp $WindowsTerminal 'themeDefinition')) + actions = [bool]@($Keybindings | Where-Object { $null -ne $_ }).Count + } + foreach ($name in $wantsArray.Keys) { + if ($wantsArray[$name] -and -not (Test-PspProp $wt $name) -and -not $created.Contains($name)) { $created.Add($name) } + } + if ($created.Count -gt 0) { Set-PspProp $entry 'createdArrays' ([object[]]$created.ToArray()) } + if ($null -ne $Defaults) { + foreach ($p in $Defaults.PSObject.Properties) { + Set-WtRecordedValue -Container $defaultsNode -Name $p.Name -Value $p.Value -Section $entry.defaults -Baseline $baseDefaults -LegacyBaseline $legacyDefaults + } + } + foreach ($name in $RemoveDefaults) { + if (Test-PspProp $defaultsNode $name) { + Set-WtRecordedValue -Container $defaultsNode -Name $name -Value $null -Section $entry.defaults -Baseline $baseDefaults -Remove + } + } + if ($null -ne $WindowsTerminal) { + foreach ($name in 'colorScheme', 'cursorColor') { + $value = Get-PspProp $WindowsTerminal $name + if ($value) { Set-WtRecordedValue -Container $defaultsNode -Name $name -Value $value -Section $entry.defaults -Baseline $baseDefaults -LegacyBaseline $legacyDefaults } + } + $scheme = Get-PspProp $WindowsTerminal 'scheme' + if ($scheme) { + $schemeObj = Copy-PspObject ([PSCustomObject]$scheme) + if (-not (Get-PspProp $schemeObj 'name') -and (Get-PspProp $WindowsTerminal 'colorScheme')) { Set-PspProp $schemeObj 'name' $WindowsTerminal.colorScheme } + Set-WtRecordedArrayItem -Root $wt -ArrayName 'schemes' -Item $schemeObj -Section $entry.schemes -Baseline $baseline -LegacyBaseline $legacyBaseline + } + $themeDef = Get-PspProp $WindowsTerminal 'themeDefinition' + if ($themeDef) { + Set-WtRecordedArrayItem -Root $wt -ArrayName 'themes' -Item (Copy-PspObject ([PSCustomObject]$themeDef)) -Section $entry.themes -Baseline $baseline -LegacyBaseline $legacyBaseline + } + $themeName = Get-PspProp $WindowsTerminal 'theme' + if ($themeName) { Set-WtRecordedValue -Container $wt -Name 'theme' -Value $themeName -Section $entry.root -Baseline $baseline -LegacyBaseline $legacyBaseline } + } + foreach ($kb in @($Keybindings | Where-Object { $null -ne $_ })) { + Set-WtRecordedKeybinding -Root $wt -Binding $kb -Section $entry.bindings -Baseline $baseline -LegacyBaseline $legacyBaseline + } + if ($NoLogo) { Set-WtRecordedNoLogo -Root $wt -Section $entry.commandlines -Baseline $baseline -LegacyBaseline $legacyBaseline } + + # Deeply nested Windows Terminal action objects need the full depth. + Write-Utf8FileAtomic -Path $Path -Content ($wt | ConvertTo-Json -Depth 100) + Save-WtChangeManifest -ManifestPath $ManifestPath -Manifest $manifest + return $true +} + +# Return the name/record pairs stored in one section of a manifest entry. +function Get-WtChangeRecords { + param([AllowNull()][object]$Entry, [Parameter(Mandatory)][string]$Section) + $node = Get-PspProp $Entry $Section + if ($null -eq $node) { return } + foreach ($p in $node.PSObject.Properties) { $p } +} + +# Restore one recorded value when the user has not changed it since the profile wrote it. +function Restore-WtRecordedValue { + param([AllowNull()][object]$Container, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][object]$Record) + if ($null -eq $Container) { return $false } + $has = Test-PspProp $Container $Name + $untouched = if ($Record.removed) { -not $has } else { $has -and (Test-PspJsonEqual (Get-PspProp $Container $Name) $Record.applied) } + if (-not $untouched) { return $false } + if ($Record.existed) { Set-PspProp $Container $Name $Record.original } else { Remove-PspProp $Container $Name } + return $true +} + +# Restore a recorded scheme or theme entry when it still matches what the profile wrote. +function Restore-WtRecordedArrayItem { + param([Parameter(Mandatory)][object]$Root, [Parameter(Mandatory)][string]$ArrayName, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][object]$Record) + $items = Get-PspArray $Root $ArrayName + $current = $items | Where-Object { (Get-PspProp $_ 'name') -eq $Name } | Select-Object -First 1 + if ($null -eq $current -or -not (Test-PspJsonEqual $current $Record.applied)) { return $false } + $rest = @($items | Where-Object { (Get-PspProp $_ 'name') -ne $Name }) + if ($Record.existed -and $null -ne $Record.original) { $rest += $Record.original } + Set-PspProp $Root $ArrayName ([object[]]$rest) + return $true +} + +# Remove a recorded keybinding and put back what it replaced, unless the user changed it. +function Restore-WtRecordedKeybinding { + param([Parameter(Mandatory)][object]$Root, [Parameter(Mandatory)][object]$Record) + $actions = Get-PspArray $Root 'actions' + if (-not ($actions | Where-Object { Test-PspJsonEqual $_ $Record.appliedAction })) { return $false } + if ($Record.format -eq 'new' -and (Test-PspProp $Root 'keybindings')) { + $bindings = @((Get-PspArray $Root 'keybindings') | Where-Object { -not (Test-PspJsonEqual $_ $Record.appliedBinding) }) + # Keys the user has since bound to this action keep it. + $actionId = [string](Get-PspProp $Record.appliedAction 'id') + if ($actionId -and ($bindings | Where-Object { (Get-PspProp $_ 'id') -eq $actionId })) { return $false } + } + $actions = @($actions | Where-Object { -not (Test-PspJsonEqual $_ $Record.appliedAction) }) + if ($Record.format -eq 'new' -and (Test-PspProp $Root 'keybindings')) { + foreach ($b in (Get-PspArray $Record 'removedBindings')) { + if (-not ($bindings | Where-Object { (Get-PspProp $_ 'keys') -eq (Get-PspProp $b 'keys') })) { $bindings += $b } + } + Set-PspProp $Root 'keybindings' ([object[]]$bindings) + } + foreach ($a in (Get-PspArray $Record 'removedActions')) { + if (-not ($actions | Where-Object { Test-PspJsonEqual $_ $a })) { $actions += $a } + } + Set-PspProp $Root 'actions' ([object[]]$actions) + return $true +} + +# Revert the recorded changes in one settings.json, keeping every value the user changed after the profile wrote it. +function Undo-WindowsTerminalSettingsFile { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$ManifestPath) + $manifest = Read-WtChangeManifest -ManifestPath $ManifestPath + $key = $Path.ToLowerInvariant() + $entry = Get-PspProp $manifest.files $key + if (-not $entry) { return $null } + $wt = ConvertFrom-JsonC -Text (Get-Utf8FileText $Path) + if ($null -eq $wt) { $wt = [PSCustomObject]@{} } + $reverted = 0 + $kept = 0 + $defaultsNode = Get-PspPath $wt 'profiles.defaults' + foreach ($p in @(Get-WtChangeRecords $entry 'defaults')) { + if (Restore-WtRecordedValue -Container $defaultsNode -Name $p.Name -Record $p.Value) { $reverted++ } else { $kept++ } + } + foreach ($p in @(Get-WtChangeRecords $entry 'root')) { + if (Restore-WtRecordedValue -Container $wt -Name $p.Name -Record $p.Value) { $reverted++ } else { $kept++ } + } + foreach ($arrayName in 'schemes', 'themes') { + foreach ($p in @(Get-WtChangeRecords $entry $arrayName)) { + if (Restore-WtRecordedArrayItem -Root $wt -ArrayName $arrayName -Name $p.Name -Record $p.Value) { $reverted++ } else { $kept++ } + } + } + foreach ($p in @(Get-WtChangeRecords $entry 'bindings')) { + if (Restore-WtRecordedKeybinding -Root $wt -Record $p.Value) { $reverted++ } else { $kept++ } + } + $list = Get-PspArray (Get-PspProp $wt 'profiles') 'list' + foreach ($p in @(Get-WtChangeRecords $entry 'commandlines')) { + $recordKey = $p.Name + $prof = $list | Where-Object { + if ($recordKey -like 'guid:*') { ('guid:' + (Get-PspProp $_ 'guid')) -eq $recordKey } else { ('name:' + (Get-PspProp $_ 'name')) -eq $recordKey } + } | Select-Object -First 1 + if ($prof -and (Restore-WtRecordedValue -Container $prof -Name 'commandline' -Record $p.Value)) { $reverted++ } else { $kept++ } + } + foreach ($arrayName in (Get-PspArray $entry 'createdArrays')) { + if ((Test-PspProp $wt $arrayName) -and (Get-PspArray $wt $arrayName).Count -eq 0) { Remove-PspProp $wt $arrayName } + } + if (-not $PSCmdlet.ShouldProcess($Path, "Revert $reverted Windows Terminal change(s) made by this profile")) { return $null } + Write-Utf8FileAtomic -Path $Path -Content ($wt | ConvertTo-Json -Depth 100) + Remove-PspProp $manifest.files $key + Save-WtChangeManifest -ManifestPath $ManifestPath -Manifest $manifest + return [PSCustomObject]@{ Reverted = $reverted; Kept = $kept } +} + +# Return the timestamped settings.json backups and the durable pre-install copy the profile created next to a settings file. +function Get-WtProfileBackups { + param([Parameter(Mandatory)][string]$Path) + $dir = Split-Path -Parent $Path + $leaf = Split-Path -Leaf $Path + @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue | Where-Object { + $_.Name -eq "$leaf.psp-original" -or ($_.Name.StartsWith("$leaf.") -and $_.Name -match '\.\d{8}-\d{6}\.bak$') + }) +} + # Specific helper to get the path to oh-my-posh executable for cache clearing (since it has a built-in cache clear command instead of a file-based cache) function Get-OhMyPoshExecutablePath { $candidatePaths = @( @@ -515,6 +1254,17 @@ function Invoke-OhMyPoshCommand { try { $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() + # A hung segment must not hang the prompt; callers fall back to the plain prompt on $null. + $timeoutMs = if ($script:PSP -and $script:PSP.PromptTimeoutMs) { [int]$script:PSP.PromptTimeoutMs } else { 5000 } + if (-not $process.WaitForExit($timeoutMs)) { + # Stop the whole tree: segments such as git run as child processes. + try { + if ($PSVersionTable.PSVersion.Major -ge 7) { $process.Kill($true) } + else { $null = & (Join-Path $env:SystemRoot 'System32\taskkill.exe') /T /F /PID $process.Id 2>&1 } + } + catch { $null = $_ } + return $null + } $process.WaitForExit() $stderr = $stderrTask.Result.Trim() @@ -670,21 +1420,22 @@ function Clear-OhMyPoshCaches { [switch]$Quiet ) - $docRoot = [Environment]::GetFolderPath('MyDocuments') + # Derive the Documents root from $PROFILE like the installer does, so redirected sessions stay inside their sandbox. + $docRoot = Split-Path (Split-Path $PROFILE) $legacyCachePaths = @( - (Join-Path $env:LOCALAPPDATA 'PowerShellProfile\omp-init.ps1') + (Join-Path (Get-PspCacheDir) 'omp-init.ps1') (Join-Path $docRoot 'PowerShell\omp-init.ps1') (Join-Path $docRoot 'WindowsPowerShell\omp-init.ps1') ) | Select-Object -Unique foreach ($legacyPath in $legacyCachePaths) { - if (-not (Test-Path $legacyPath)) { continue } + if (-not (Test-Path -LiteralPath $legacyPath)) { continue } try { - $firstLine = Get-Content $legacyPath -TotalCount 1 -ErrorAction Stop + $firstLine = Get-Content -LiteralPath $legacyPath -TotalCount 1 -ErrorAction Stop if ($firstLine -match '^# OMP_CACHE') { - Remove-Item $legacyPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $legacyPath -Force -ErrorAction SilentlyContinue if (-not $Quiet) { - Write-Host " Removed legacy OMP init cache: $legacyPath" -ForegroundColor DarkGray + Write-PspHost " Removed legacy OMP init cache: $legacyPath" -ForegroundColor DarkGray } } } @@ -711,16 +1462,117 @@ function Clear-OhMyPoshCaches { } } -# Check for Profile Updates (manual only) +# Return the starter user-settings.json written by setup.ps1 and Update-Profile when none exists. +function Get-PspUserSettingsTemplate { + @' +{ + "_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/v31.8.0/themes/catppuccin.omp.json" }, + "windowsTerminal": { "manage": true, "colorScheme": "One Half Dark", "cursorColor": "#ffffff" }, + "_manage_note": "set windowsTerminal.manage to false when another tool (for example a dotfiles repo) owns Windows Terminal settings.json; the profile then leaves that file alone", + "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": [] + } +} +'@ +} + +# Apply user-settings.json overrides to the downloaded theme and terminal configuration; returns what the overrides contained. +function Merge-PspUserSettings { + param( + [AllowNull()][object]$UserSettings, + [AllowNull()][object]$ThemeConfig, + [AllowNull()][object]$TerminalConfig + ) + $result = [PSCustomObject]@{ Theme = $false; WindowsTerminal = $false; Defaults = $false; Keybindings = $false; Managed = $true } + if ($null -eq $UserSettings) { return $result } + $userTheme = Get-PspProp $UserSettings 'theme' + $userWt = Get-PspProp $UserSettings 'windowsTerminal' + $userDefaults = Get-PspProp $UserSettings 'defaults' + $userKeys = Get-PspProp $UserSettings 'keybindings' + $result.Theme = $null -ne $userTheme + $result.WindowsTerminal = $null -ne $userWt + $result.Defaults = $null -ne $userDefaults + $result.Keybindings = $null -ne $userKeys + # windowsTerminal.manage = false hands settings.json to another tool, such as a dotfiles repo. + if ($userWt -and (Test-PspProp $userWt 'manage') -and $userWt.manage -eq $false) { $result.Managed = $false } + if ($ThemeConfig -and $userTheme) { + if (-not (Get-PspProp $ThemeConfig 'theme')) { Set-PspProp $ThemeConfig 'theme' ([PSCustomObject]@{}) } + Merge-JsonObject $ThemeConfig.theme $userTheme + # A user-chosen theme URL comes without the pinned hash of the default theme. + if ((Test-PspProp $userTheme 'url') -and -not (Test-PspProp $userTheme 'sha256')) { Remove-PspProp $ThemeConfig.theme 'sha256' } + } + if ($ThemeConfig -and $userWt) { + if (-not (Get-PspProp $ThemeConfig 'windowsTerminal')) { Set-PspProp $ThemeConfig 'windowsTerminal' ([PSCustomObject]@{}) } + Merge-JsonObject $ThemeConfig.windowsTerminal $userWt + } + if ($TerminalConfig -and $userDefaults) { + if (-not (Get-PspProp $TerminalConfig 'defaults')) { Set-PspProp $TerminalConfig 'defaults' ([PSCustomObject]@{}) } + Merge-JsonObject $TerminalConfig.defaults $userDefaults + } + if ($TerminalConfig -and $userKeys) { + Set-PspProp $TerminalConfig 'keybindings' ([object[]]((Get-PspArray $TerminalConfig 'keybindings') + @($userKeys))) + } + return $result +} + +# Return $true when a URL is an HTTPS raw.githubusercontent.com address (the only host themes are fetched from). +function Test-PspTrustedThemeUrl { + param([AllowNull()][string]$Url) + try { $uri = [Uri]$Url } catch { return $false } + $uri.Scheme -eq 'https' -and $uri.Host -in @('raw.githubusercontent.com', 'githubusercontent.com') +} + +# Download profile, theme.json and terminal-config.json from a branch, tag or commit and apply them after hash verification. function Update-Profile { + <# + .SYNOPSIS + Sync profile, theme, caches, WT settings from a branch, tag or commit. + .EXAMPLE + Update-Profile -Ref v1.1.0 + Install release v1.1.0; the downloads are checked against its SHA256SUMS.txt. + .EXAMPLE + Update-Profile + Show the hashes of the latest main without installing anything. + .EXAMPLE + Update-Profile -InstallMissingTools + Also install managed tools that are missing (winget accepts the source and package agreements). + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedSha256, [switch]$SkipHashCheck, + # Branch, tag (vX.Y.Z) or commit; resolved to a commit so the printed hash can be reproduced. + [ValidatePattern('^(?!.*\.\.)(?!.*//)[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$')] + [string]$Ref = 'main', + # Also install missing managed tools with winget (accepting the winget source and package agreements) and the pinned PSFzf module. + [switch]$InstallMissingTools, [switch]$Force ) + $cacheDir = Get-PspCacheDir # Use randomized temporary names to avoid concurrent file collisions. $tempSuffix = [System.IO.Path]::GetRandomFileName() $tempProfile = Join-Path $env:TEMP "psp-profile-$tempSuffix.ps1" @@ -734,24 +1586,23 @@ function Update-Profile { $userSettingsHash = $null $userSettingsChanged = $false $userSettingsParsed = $false - $userThemeOverridePresent = $false - $userWindowsTerminalOverridePresent = $false - $userTerminalDefaultsOverridePresent = $false - $userKeybindingsOverridePresent = $false + $overrides = [PSCustomObject]@{ Theme = $false; WindowsTerminal = $false; Defaults = $false; Keybindings = $false; Managed = $true } + $combinedHash = $null # Gate downloads behind ShouldProcess so -WhatIf remains offline. - $updateSource = "$repo_root/$repo_name/main" - if (-not $PSCmdlet.ShouldProcess($updateSource, 'Download profile/theme/terminal-config and apply updates')) { return } + if (-not $PSCmdlet.ShouldProcess("$($script:PSP.RepoOwner)/$($script:PSP.RepoName)@$Ref", 'Download profile/theme/terminal-config and apply updates')) { return } + + # Resolve the ref once so every file comes from the same commit; fall back to the ref itself when the GitHub API is unreachable. + $commit = Resolve-PspCommitSha -Owner $script:PSP.RepoOwner -Repo $script:PSP.RepoName -Ref $Ref + $sourceRef = if ($commit) { $commit } else { $Ref } try { # Phase 1: Download profile and config - $profileUrl = "$repo_root/$repo_name/main/Microsoft.PowerShell_profile.ps1" - Invoke-DownloadWithRetry -Uri $profileUrl -OutFile $tempProfile + Invoke-DownloadWithRetry -Uri (Get-PspRawUrl -Ref $sourceRef -Path 'Microsoft.PowerShell_profile.ps1') -OutFile $tempProfile - $configUrl = "$repo_root/$repo_name/main/theme.json" $configDownloaded = $false try { - Invoke-DownloadWithRetry -Uri $configUrl -OutFile $tempConfig + Invoke-DownloadWithRetry -Uri (Get-PspRawUrl -Ref $sourceRef -Path 'theme.json') -OutFile $tempConfig $configDownloaded = $true } catch { @@ -759,10 +1610,9 @@ function Update-Profile { $phaseErrors += "theme.json download: $_" } - $terminalConfigUrl = "$repo_root/$repo_name/main/terminal-config.json" $terminalConfigDownloaded = $false try { - Invoke-DownloadWithRetry -Uri $terminalConfigUrl -OutFile $tempTerminalConfig + Invoke-DownloadWithRetry -Uri (Get-PspRawUrl -Ref $sourceRef -Path 'terminal-config.json') -OutFile $tempTerminalConfig $terminalConfigDownloaded = $true } catch { @@ -771,7 +1621,7 @@ function Update-Profile { } # Phase 2: Verify profile hashes in both PowerShell edition directories. - $newHash = (Get-FileHash -Path $tempProfile -Algorithm SHA256).Hash + $newHash = (Get-FileHash -LiteralPath $tempProfile -Algorithm SHA256).Hash $_docsRoot = Split-Path (Split-Path $PROFILE) $_editionDirs = @( (Join-Path $_docsRoot 'PowerShell') @@ -779,7 +1629,7 @@ function Update-Profile { ) $_installedProfiles = foreach ($_ed in $_editionDirs) { $_p = Join-Path $_ed 'Microsoft.PowerShell_profile.ps1' - if (Test-Path $_p) { $_p } + if (Test-Path -LiteralPath $_p) { $_p } } $profileChanged = $false if (-not $_installedProfiles) { @@ -788,7 +1638,7 @@ function Update-Profile { } else { foreach ($_ip in $_installedProfiles) { - if ((Get-FileHash -Path $_ip -Algorithm SHA256).Hash -ne $newHash) { + if ((Get-FileHash -LiteralPath $_ip -Algorithm SHA256).Hash -ne $newHash) { $profileChanged = $true break } @@ -797,27 +1647,29 @@ function Update-Profile { # Check if config actually changed $configChanged = $false + $newConfigHash = $null $cachedConfig = Join-Path $cacheDir "theme.json" if ($configDownloaded) { - $newConfigHash = (Get-FileHash -Path $tempConfig -Algorithm SHA256).Hash - $oldConfigHash = if (Test-Path $cachedConfig) { (Get-FileHash -Path $cachedConfig -Algorithm SHA256).Hash } else { "" } + $newConfigHash = (Get-FileHash -LiteralPath $tempConfig -Algorithm SHA256).Hash + $oldConfigHash = if (Test-Path -LiteralPath $cachedConfig) { (Get-FileHash -LiteralPath $cachedConfig -Algorithm SHA256).Hash } else { "" } $configChanged = $newConfigHash -ne $oldConfigHash } # Check if terminal config actually changed $terminalConfigChanged = $false + $newTerminalConfigHash = $null $cachedTerminalConfig = Join-Path $cacheDir "terminal-config.json" if ($terminalConfigDownloaded) { - $newTerminalConfigHash = (Get-FileHash -Path $tempTerminalConfig -Algorithm SHA256).Hash - $oldTerminalConfigHash = if (Test-Path $cachedTerminalConfig) { (Get-FileHash -Path $cachedTerminalConfig -Algorithm SHA256).Hash } else { "" } + $newTerminalConfigHash = (Get-FileHash -LiteralPath $tempTerminalConfig -Algorithm SHA256).Hash + $oldTerminalConfigHash = if (Test-Path -LiteralPath $cachedTerminalConfig) { (Get-FileHash -LiteralPath $cachedTerminalConfig -Algorithm SHA256).Hash } else { "" } $terminalConfigChanged = $newTerminalConfigHash -ne $oldTerminalConfigHash } - if (Test-Path $userSettingsPath) { + if (Test-Path -LiteralPath $userSettingsPath) { try { - $userSettingsHash = (Get-FileHash -Path $userSettingsPath -Algorithm SHA256).Hash - $appliedUserSettingsHash = if (Test-Path $userSettingsStatePath) { - (Get-Content $userSettingsStatePath -Raw -ErrorAction Stop).Trim() + $userSettingsHash = (Get-FileHash -LiteralPath $userSettingsPath -Algorithm SHA256).Hash + $appliedUserSettingsHash = if (Test-Path -LiteralPath $userSettingsStatePath) { + (Get-Utf8FileText $userSettingsStatePath).Trim() } else { "" @@ -831,49 +1683,45 @@ function Update-Profile { } } - if (-not $profileChanged -and -not $configChanged -and -not $terminalConfigChanged -and -not $userSettingsChanged -and -not $Force) { - Write-Host "Profile is up to date." -ForegroundColor Green + if (-not $profileChanged -and -not $configChanged -and -not $terminalConfigChanged -and -not $userSettingsChanged -and -not $Force -and -not $InstallMissingTools) { + Write-PspHost "Profile is up to date." -ForegroundColor Green return } # Combined hash verification - covers profile + config files (skipped when nothing changed upstream) if (-not $SkipHashCheck -and ($profileChanged -or $configChanged -or $terminalConfigChanged)) { - $profileLabel = $newHash $configLabel = if ($configDownloaded) { $newConfigHash } else { "NONE" } $terminalLabel = if ($terminalConfigDownloaded) { $newTerminalConfigHash } else { "NONE" } - $combinedInput = "profile:${profileLabel}:theme:${configLabel}:terminal:${terminalLabel}" - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - $combinedHash = [BitConverter]::ToString( - $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($combinedInput)) - ).Replace('-', '') - } - finally { $sha.Dispose() } - - if (-not $ExpectedSha256) { - 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 - } - else { - Write-Host " theme.json: (not downloaded)" -ForegroundColor Yellow - } - if ($terminalConfigDownloaded) { - Write-Host " terminal-config: $newTerminalConfigHash" -ForegroundColor Yellow - } - else { - Write-Host " terminal-config: (not downloaded)" -ForegroundColor Yellow + $combinedHash = Get-CombinedSha256 -Parts @("profile:$newHash", "theme:$configLabel", "terminal:$terminalLabel") + + # A release tag is checked against the SHA256SUMS.txt published with that release. + $releaseSums = $null + if (-not $ExpectedSha256) { $releaseSums = Get-PspReleaseChecksums -Owner $script:PSP.RepoOwner -Repo $script:PSP.RepoName -Tag $Ref } + if ($releaseSums) { + $releaseChecks = @( + @('Microsoft.PowerShell_profile.ps1', $newHash, $true), + @('theme.json', $newConfigHash, $configDownloaded), + @('terminal-config.json', $newTerminalConfigHash, $terminalConfigDownloaded) + ) + foreach ($check in $releaseChecks) { + if (-not $check[2]) { throw "$($check[0]) could not be downloaded, so release $Ref cannot be verified." } + if ($releaseSums[$check[0]] -ne $check[1]) { throw "$($check[0]) does not match the SHA256SUMS.txt of release $Ref." } } - Write-Host " combined: $combinedHash" -ForegroundColor Yellow - 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." + Write-PspHost "Downloaded files match the SHA256SUMS.txt of release $Ref." -ForegroundColor Green } - $expected = $ExpectedSha256.ToUpperInvariant() - if ($combinedHash -ne $expected) { - throw "Combined hash mismatch. Expected $expected, got $combinedHash." + elseif (-not $ExpectedSha256) { + Write-PspHost "Downloaded file hashes (computed over what was just fetched from $sourceRef):" -ForegroundColor Yellow + Write-PspHost " profile.ps1: $newHash" -ForegroundColor Yellow + Write-PspHost (" theme.json: {0}" -f $(if ($configDownloaded) { $newConfigHash } else { '(not downloaded)' })) -ForegroundColor Yellow + Write-PspHost (" terminal-config: {0}" -f $(if ($terminalConfigDownloaded) { $newTerminalConfigHash } else { '(not downloaded)' })) -ForegroundColor Yellow + Write-PspHost " combined: $combinedHash" -ForegroundColor Yellow + Write-PspHost "Release tags (Update-Profile -Ref vX.Y.Z) are verified automatically against the release's SHA256SUMS.txt." -ForegroundColor DarkYellow + Write-PspHost "For a branch or commit, check the commit out-of-band first: https://github.com/$($script:PSP.RepoOwner)/$($script:PSP.RepoName)/commit/$sourceRef" -ForegroundColor DarkYellow + $rerunTools = if ($InstallMissingTools) { ' -InstallMissingTools' } else { '' } + throw "Hash input required. Re-run with: Update-Profile -Ref $sourceRef -ExpectedSha256 '$combinedHash'$rerunTools (or -SkipHashCheck)." + } + elseif ($combinedHash -ne $ExpectedSha256.ToUpperInvariant()) { + throw "Combined hash mismatch. Expected $($ExpectedSha256.ToUpperInvariant()), got $combinedHash." } } @@ -886,10 +1734,10 @@ function Update-Profile { Join-Path $docsRoot "WindowsPowerShell" ) $currentEditionDir = Split-Path $PROFILE - if (-not (Test-Path $currentEditionDir)) { + if (-not (Test-Path -LiteralPath $currentEditionDir)) { try { New-Item -ItemType Directory -Path $currentEditionDir -Force | Out-Null - Write-Host "Created profile directory: $currentEditionDir" -ForegroundColor DarkGray + Write-PspHost "Created profile directory: $currentEditionDir" -ForegroundColor DarkGray } catch { Write-Warning "Failed to create profile directory $currentEditionDir`: $_" @@ -899,21 +1747,21 @@ function Update-Profile { $copyFailed = @() foreach ($dir in $profileDirs) { $target = Join-Path $dir "Microsoft.PowerShell_profile.ps1" - if (Test-Path $dir) { + if (Test-Path -LiteralPath $dir) { # Back up the current profile before overwriting it, keeping the latest five # (profile.original.ps1 is never matched, so the first-install copy survives). - if (Test-Path -Path $target -PathType Leaf) { + if (Test-Path -LiteralPath $target -PathType Leaf) { try { $backupStamp = Get-Date -Format 'yyyyMMdd-HHmmss' - Copy-Item -Path $target -Destination (Join-Path $dir "oldprofile.$backupStamp.ps1") -Force -ErrorAction Stop - Get-ChildItem -Path $dir -Filter 'oldprofile*.ps1' -ErrorAction SilentlyContinue | + Copy-Item -LiteralPath $target -Destination (Join-Path $dir "oldprofile.$backupStamp.ps1") -Force -ErrorAction Stop + Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | - ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue } + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } } catch { Write-Warning "Could not back up $target before update: $_" } } try { - Copy-Item -Path $tempProfile -Destination $target -Force -ErrorAction Stop + Copy-Item -LiteralPath $tempProfile -Destination $target -Force -ErrorAction Stop $copySuccess++ } catch { @@ -923,7 +1771,7 @@ function Update-Profile { } } if ($copySuccess -gt 0 -and $copyFailed.Count -eq 0) { - Write-Host "Profile updated ($copySuccess locations)." -ForegroundColor Green + Write-PspHost "Profile updated ($copySuccess locations)." -ForegroundColor Green $profileActuallyUpdated = $true } elseif ($copySuccess -gt 0) { @@ -936,143 +1784,86 @@ function Update-Profile { } } else { - Write-Host "Profile .ps1 unchanged, applying config updates..." -ForegroundColor Cyan + Write-PspHost "Profile .ps1 unchanged, applying config updates..." -ForegroundColor Cyan } # Load config for remaining phases. $config = $null if ($configDownloaded) { - try { $config = Get-Content $tempConfig -Raw | ConvertFrom-Json } + try { $config = Read-JsonFile $tempConfig } catch { Write-Verbose "Failed to parse downloaded config: $_" } } - elseif (Test-Path $cachedConfig) { - try { $config = Get-Content $cachedConfig -Raw | ConvertFrom-Json } + elseif (Test-Path -LiteralPath $cachedConfig) { + try { $config = Read-JsonFile $cachedConfig } catch { Write-Warning "Corrupt cached config removed: $cachedConfig" - Remove-Item $cachedConfig -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $cachedConfig -Force -ErrorAction SilentlyContinue } } - # Load terminal config for Phase 7 + # Load terminal config for the Windows Terminal phase. $terminalConfig = $null if ($terminalConfigDownloaded) { - try { $terminalConfig = Get-Content $tempTerminalConfig -Raw | ConvertFrom-Json } + try { $terminalConfig = Read-JsonFile $tempTerminalConfig } catch { Write-Verbose "Failed to parse downloaded terminal config: $_" } } - elseif (Test-Path $cachedTerminalConfig) { - try { $terminalConfig = Get-Content $cachedTerminalConfig -Raw | ConvertFrom-Json } + elseif (Test-Path -LiteralPath $cachedTerminalConfig) { + try { $terminalConfig = Read-JsonFile $cachedTerminalConfig } catch { Write-Warning "Corrupt cached terminal config removed: $cachedTerminalConfig" - Remove-Item $cachedTerminalConfig -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $cachedTerminalConfig -Force -ErrorAction SilentlyContinue } } # Apply user-settings.json overrides (never downloaded, never overwritten) - $wtManaged = $true - if (Test-Path $userSettingsPath) { + if (Test-Path -LiteralPath $userSettingsPath) { try { - $userSettings = Get-Utf8FileText $userSettingsPath | ConvertFrom-Json + $userSettings = Read-JsonFile $userSettingsPath $userSettingsParsed = $true - $userThemeOverridePresent = $null -ne $userSettings.theme - $userWindowsTerminalOverridePresent = $null -ne $userSettings.windowsTerminal - # windowsTerminal.manage = false hands settings.json to another tool, such as a dotfiles repo. - if ($userSettings.windowsTerminal -and $userSettings.windowsTerminal.PSObject.Properties['manage'] -and $userSettings.windowsTerminal.manage -eq $false) { $wtManaged = $false } - $userTerminalDefaultsOverridePresent = $null -ne $userSettings.defaults - $userKeybindingsOverridePresent = $null -ne $userSettings.keybindings - if ($config -and $userSettings.theme) { - if (-not $config.theme) { - $config | Add-Member -NotePropertyName "theme" -NotePropertyValue ([PSCustomObject]@{}) -Force - } - Merge-JsonObject $config.theme $userSettings.theme - } - if ($config -and $userSettings.windowsTerminal) { - if (-not $config.windowsTerminal) { - $config | Add-Member -NotePropertyName "windowsTerminal" -NotePropertyValue ([PSCustomObject]@{}) -Force - } - Merge-JsonObject $config.windowsTerminal $userSettings.windowsTerminal - } - if ($terminalConfig -and $userSettings.defaults) { - if (-not $terminalConfig.defaults) { - $terminalConfig | Add-Member -NotePropertyName "defaults" -NotePropertyValue ([PSCustomObject]@{}) -Force - } - Merge-JsonObject $terminalConfig.defaults $userSettings.defaults - } - if ($terminalConfig -and $userSettings.keybindings) { - if (-not $terminalConfig.keybindings) { - $terminalConfig | Add-Member -NotePropertyName "keybindings" -NotePropertyValue @() -Force - } - $terminalConfig.keybindings = @($terminalConfig.keybindings) + @($userSettings.keybindings) - } - Write-Host "User overrides applied from user-settings.json" -ForegroundColor DarkGray + $overrides = Merge-PspUserSettings -UserSettings $userSettings -ThemeConfig $config -TerminalConfig $terminalConfig + Write-PspHost "User overrides applied from user-settings.json" -ForegroundColor DarkGray } catch { - Write-Warning "Failed to parse user-settings.json: $_" + # Without the file it is unknown whether another tool owns Windows Terminal's settings.json, so leave it alone. + $overrides.Managed = $false + Write-Warning "Failed to parse user-settings.json: $_. Windows Terminal settings are left alone until it parses." } } else { # Create the same starter user-settings template used by setup.ps1. - $userSettingsTemplate = @' -{ - "_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": { "manage": true, "colorScheme": "One Half Dark", "cursorColor": "#ffffff" }, - "_manage_note": "set windowsTerminal.manage to false when another tool (for example a dotfiles repo) owns Windows Terminal settings.json; the profile then leaves that file alone", - "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": [] - } -} -'@ if ($PSCmdlet.ShouldProcess($userSettingsPath, "Create user-settings.json template")) { - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($userSettingsPath, $userSettingsTemplate, $utf8NoBom) - Write-Host "Created user-settings.json template in $cacheDir" -ForegroundColor Green + if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } + Write-Utf8FileAtomic -Path $userSettingsPath -Content (Get-PspUserSettingsTemplate) + Write-PspHost "Created user-settings.json template in $cacheDir" -ForegroundColor Green } } # Phase 4: OMP theme sync + orphan cleanup - if ($config -and $config.theme -and $config.theme.name) { - $themeName = $config.theme.name - $themeUrl = $config.theme.url + if ($config -and $config.theme -and $config.theme.name -and [string]$config.theme.name -notmatch '^[A-Za-z0-9._-]+$') { + Write-Warning "Ignoring OMP theme with an invalid name: $($config.theme.name)" + $phaseErrors += "OMP theme name invalid: $($config.theme.name)" + } + elseif ($config -and $config.theme -and $config.theme.name) { + $themeName = [string]$config.theme.name + $themeUrl = [string](Get-PspProp $config.theme 'url') + $themeSha256 = [string](Get-PspProp $config.theme 'sha256') $localThemePath = Join-Path $cacheDir "$themeName.omp.json" $currentThemeReady = $false - if (Test-Path $localThemePath) { + if (Test-Path -LiteralPath $localThemePath) { try { - $existingThemeContent = Get-Content $localThemePath -Raw -ErrorAction Stop - if ([string]::IsNullOrWhiteSpace($existingThemeContent)) { throw 'Theme file is empty' } - $null = $existingThemeContent | ConvertFrom-Json - $currentThemeReady = $true + if ($null -eq (Read-JsonFile $localThemePath)) { throw 'Theme file is empty' } + $currentThemeReady = -not $themeSha256 -or ((Get-FileHash -LiteralPath $localThemePath -Algorithm SHA256).Hash -eq $themeSha256.ToUpperInvariant()) } catch { Write-Warning "Existing OMP theme '$themeName' is invalid at '$localThemePath': $_" } } - $themeOverrideChanged = $userSettingsChanged -and $userThemeOverridePresent + $themeOverrideChanged = $userSettingsChanged -and $overrides.Theme $shouldDownloadTheme = $Force -or (-not $currentThemeReady) -or $configChanged -or $themeOverrideChanged $themeUrlTrusted = $false if ($themeUrl) { - try { $themeUri = [Uri]$themeUrl; $themeUrlTrusted = $themeUri.Scheme -eq 'https' -and $themeUri.Host -in @('raw.githubusercontent.com', 'githubusercontent.com') } catch { $themeUrlTrusted = $false } + $themeUrlTrusted = Test-PspTrustedThemeUrl $themeUrl if (-not $themeUrlTrusted) { Write-Warning "Skipping OMP theme download from untrusted URL: $themeUrl" } } if ($shouldDownloadTheme -and $themeUrl -and $themeUrlTrusted) { @@ -1080,11 +1871,13 @@ function Update-Profile { $tempThemePath = Join-Path $cacheDir ("{0}.{1}.download" -f $themeName, [System.IO.Path]::GetRandomFileName()) try { Invoke-DownloadWithRetry -Uri $themeUrl -OutFile $tempThemePath - $downloadedThemeContent = Get-Content $tempThemePath -Raw -ErrorAction Stop - if ([string]::IsNullOrWhiteSpace($downloadedThemeContent)) { throw 'Downloaded theme file is empty' } - $null = $downloadedThemeContent | ConvertFrom-Json - Move-Item -Path $tempThemePath -Destination $localThemePath -Force - Write-Host "OMP theme '$themeName' updated." -ForegroundColor Green + if ($null -eq (Read-JsonFile $tempThemePath)) { throw 'Downloaded theme file is empty' } + if ($themeSha256) { + $actualThemeHash = (Get-FileHash -LiteralPath $tempThemePath -Algorithm SHA256).Hash + if ($actualThemeHash -ne $themeSha256.ToUpperInvariant()) { throw "Theme hash mismatch (expected $themeSha256, got $actualThemeHash)" } + } + Move-Item -LiteralPath $tempThemePath -Destination $localThemePath -Force + Write-PspHost "OMP theme '$themeName' updated." -ForegroundColor Green $currentThemeReady = $true } catch { @@ -1092,7 +1885,7 @@ function Update-Profile { $phaseErrors += "OMP theme download: $_" } finally { - Remove-Item $tempThemePath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempThemePath -Force -ErrorAction SilentlyContinue } } } @@ -1101,26 +1894,13 @@ function Update-Profile { $phaseErrors += "OMP theme missing URL: $themeName" } - # Orphan cleanup - remove *.omp.json files that don't match current theme - if (-not $currentThemeReady -and (Test-Path $localThemePath)) { - try { - $currentThemeContent = Get-Content $localThemePath -Raw -ErrorAction Stop - if ([string]::IsNullOrWhiteSpace($currentThemeContent)) { throw 'Theme file is empty' } - $null = $currentThemeContent | ConvertFrom-Json - $currentThemeReady = $true - } - catch { - Write-Warning "Skipping orphan cleanup because current OMP theme '$themeName' is still invalid: $_" - } - } - if ($currentThemeReady) { - $ompFiles = Get-ChildItem -Path $cacheDir -Filter "*.omp.json" -ErrorAction SilentlyContinue + $ompFiles = Get-ChildItem -LiteralPath $cacheDir -Filter "*.omp.json" -ErrorAction SilentlyContinue foreach ($file in $ompFiles) { if ($file.Name -ne "$themeName.omp.json") { if ($PSCmdlet.ShouldProcess($file.FullName, "Remove orphaned OMP theme")) { - Remove-Item $file.FullName -Force -ErrorAction SilentlyContinue - Write-Host "Removed orphaned theme: $($file.Name)" -ForegroundColor DarkGray + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue + Write-PspHost "Removed orphaned theme: $($file.Name)" -ForegroundColor DarkGray } } } @@ -1132,251 +1912,132 @@ function Update-Profile { foreach ($tool in $script:ProfileTools) { if ($tool.Cache) { $cachePath = Join-Path $cacheDir $tool.Cache - if (Test-Path $cachePath) { + if (Test-Path -LiteralPath $cachePath) { if ($PSCmdlet.ShouldProcess($cachePath, "Invalidate $($tool.Name) init cache")) { - Remove-Item $cachePath -Force -ErrorAction SilentlyContinue - Write-Host "$($tool.Name) init cache cleared." -ForegroundColor DarkGray + Remove-Item -LiteralPath $cachePath -Force -ErrorAction SilentlyContinue + Write-PspHost "$($tool.Name) init cache cleared." -ForegroundColor DarkGray } } } } } - # Phase 6: Synchronize every installed Windows Terminal variant. - $terminalOverridesChanged = $userSettingsChanged -and ($userWindowsTerminalOverridePresent -or $userTerminalDefaultsOverridePresent -or $userKeybindingsOverridePresent) - if (-not $wtManaged) { - Write-Host "Windows Terminal settings left alone (windowsTerminal.manage = false in user-settings.json)." -ForegroundColor DarkGray + # Phase 6: Synchronize every installed Windows Terminal variant through the change-recording writer. + $terminalOverridesChanged = $userSettingsChanged -and ($overrides.WindowsTerminal -or $overrides.Defaults -or $overrides.Keybindings) + if (-not $overrides.Managed) { + Write-PspHost "Windows Terminal settings left alone (windowsTerminal.manage = false, or user-settings.json could not be parsed)." -ForegroundColor DarkGray } elseif (($Force -or $profileChanged -or $configChanged -or $terminalConfigChanged -or $terminalOverridesChanged) -and (($config -and $config.windowsTerminal) -or $terminalConfig)) { - $wtSettingsPaths = Get-WindowsTerminalSettingsPaths - foreach ($wtSettingsPath in $wtSettingsPaths) { - if ($PSCmdlet.ShouldProcess($wtSettingsPath, "Update Windows Terminal settings")) { - try { - $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" - $backupPath = "$wtSettingsPath.$timestamp.bak" - Copy-Item $wtSettingsPath $backupPath -Force - Write-Host "WT backup: $backupPath" -ForegroundColor DarkGray - - # Cleanup old WT backups (keep last 5) - $wtLocalState = Split-Path $wtSettingsPath - $oldBackups = Get-ChildItem -Path $wtLocalState -Filter "settings.json.*.bak" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 - foreach ($old in $oldBackups) { - Remove-Item $old.FullName -Force -ErrorAction SilentlyContinue - } - - # Read WT settings with retry. - $wt = $null - for ($wtAttempt = 1; $wtAttempt -le 2; $wtAttempt++) { - try { - $wtRaw = (Get-Utf8FileText $wtSettingsPath) -replace $jsoncCommentPattern, '' - $wt = $wtRaw | ConvertFrom-Json - break - } - catch { - if ($wtAttempt -lt 2) { - Write-Warning "WT settings parse failed, retrying in 1s..." - Start-Sleep -Seconds 1 - } - else { throw } - } - } - if (-not $wt) { $wt = [PSCustomObject]@{} } - - 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 - } - $defaults = $wt.profiles.defaults - - # Terminal-config defaults first (font, opacity, scrollbar, etc.) - if ($terminalConfig -and $terminalConfig.defaults) { - $terminalConfig.defaults.PSObject.Properties | ForEach-Object { - $defaults | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value -Force - } - } - - # Theme colors second (always win over terminal defaults) - if ($config -and $config.windowsTerminal) { - $schemeName = $config.windowsTerminal.colorScheme - if ($schemeName) { - $defaults | Add-Member -NotePropertyName "colorScheme" -NotePropertyValue $schemeName -Force - } - $cursorColor = $config.windowsTerminal.cursorColor - if ($cursorColor) { - $defaults | Add-Member -NotePropertyName "cursorColor" -NotePropertyValue $cursorColor -Force - } - - # Upsert scheme definition - $schemeDef = $config.windowsTerminal.scheme - if ($schemeDef) { - if (-not $wt.schemes) { - $wt | Add-Member -NotePropertyName "schemes" -NotePropertyValue @() -Force - } - $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 - if ($terminalConfig -and $terminalConfig.keybindings) { - if (-not $wt.actions) { - $wt | Add-Member -NotePropertyName "actions" -NotePropertyValue @() -Force - } - foreach ($kb in $terminalConfig.keybindings) { - if (-not $kb -or [string]::IsNullOrWhiteSpace($kb.keys)) { continue } - $bindingId = "User.profile.$($kb.keys -replace '[^a-zA-Z0-9]', '')" - if ($wt.PSObject.Properties['keybindings']) { - # New WT format: separate keybindings array references actions by id - $existingIds = @($wt.keybindings | Where-Object { $_.keys -eq $kb.keys } | ForEach-Object { $_.id }) - if ($existingIds.Count -gt 0) { - $wt.actions = @($wt.actions | Where-Object { $_ -and ($existingIds -notcontains $_.id) }) - $wt.keybindings = @($wt.keybindings | Where-Object { $_ -and $_.keys -ne $kb.keys }) - } - $wt.actions = @($wt.actions) + ([PSCustomObject]@{ command = $kb.command; id = $bindingId }) - $wt.keybindings = @($wt.keybindings) + ([PSCustomObject]@{ id = $bindingId; keys = $kb.keys }) - } - else { - # Old WT format: keys directly in actions - $wt.actions = @($wt.actions | Where-Object { $_ -and $_.keys -ne $kb.keys }) - $wt.actions = @($wt.actions) + ([PSCustomObject]@{ keys = $kb.keys; command = $kb.command }) - } - } - } - - # Ensure PowerShell profiles launch with -NoLogo - if ($wt.profiles.list) { - foreach ($prof in @($wt.profiles.list)) { - if (-not $prof) { continue } - $cmd = if ($prof.commandline) { $prof.commandline } else { '' } - $src = if ($prof.source) { $prof.source } else { '' } - $isPwsh = $cmd -match 'pwsh' -or $src -match 'Windows\.Terminal\.PowerShellCore' - $isPS5 = $cmd -match 'powershell\.exe' -or $prof.name -match 'Windows PowerShell' - if ($isPwsh -or $isPS5) { - if ($cmd -and $cmd -notmatch '-NoLogo' -and $cmd -notmatch '(?i)-(Command|File|EncodedCommand)') { - $prof | Add-Member -NotePropertyName "commandline" -NotePropertyValue "$cmd -NoLogo" -Force - } - elseif (-not $cmd -and $src) { - # Source-only profiles: resolve executable and set commandline with -NoLogo - $exe = if ($isPwsh) { 'pwsh.exe' } else { 'powershell.exe' } - $prof | Add-Member -NotePropertyName "commandline" -NotePropertyValue "$exe -NoLogo" -Force - } - } - } - } - - # Preserve deeply nested Windows Terminal action objects during serialization. - $wtJson = $wt | ConvertTo-Json -Depth 100 - Write-Utf8FileAtomic $wtSettingsPath $wtJson - Write-Host "Windows Terminal settings updated." -ForegroundColor Green + $manifestPath = Join-Path $cacheDir 'wt-changes.json' + foreach ($wtSettingsPath in @(Get-WindowsTerminalSettingsPaths)) { + try { + $wtArgs = @{ + Path = $wtSettingsPath + ManifestPath = $manifestPath + Defaults = Get-PspProp $terminalConfig 'defaults' + WindowsTerminal = Get-PspProp $config 'windowsTerminal' + Keybindings = Get-PspArray $terminalConfig 'keybindings' + NoLogo = $true } - catch { - Write-Warning "Failed to update Windows Terminal settings: $_" - $phaseErrors += "Windows Terminal sync: $_" + if (Update-WindowsTerminalSettingsFile @wtArgs) { + Write-PspHost "Windows Terminal settings updated: $wtSettingsPath" -ForegroundColor Green } } + catch { + Write-Warning "Failed to update Windows Terminal settings ($wtSettingsPath): $_" + $phaseErrors += "Windows Terminal sync: $_" + } } } - # Phase 7: Install missing tools - if (Get-Command winget -ErrorAction Ignore) { - $missing = $script:ProfileTools | Where-Object { -not (Get-ProfileToolExecutablePath -Tool $_) } - if ($missing) { - Write-Host "Installing missing tools..." -ForegroundColor Cyan - $installedTools = @() - foreach ($tool in $missing) { - if ($PSCmdlet.ShouldProcess($tool.Name, "Install via winget")) { - Write-Host " Installing $($tool.Name)..." -ForegroundColor Yellow - winget install -e --id $tool.Id --accept-source-agreements --accept-package-agreements - if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq -1978335185 -or $LASTEXITCODE -eq -1978335189) { - Write-Host " $($tool.Name) installed." -ForegroundColor Green - $installedTools += $tool - } - else { - Write-Warning " $($tool.Name) install may have failed (exit code: $LASTEXITCODE)" - } + # Phase 7: Managed tools are reported, and installed only on request. + $missing = @($script:ProfileTools | Where-Object { -not (Get-ProfileToolExecutablePath -Tool $_) }) + $psfzfMissing = -not (Get-Module -ListAvailable -Name PSFzf) + if (($missing.Count -gt 0 -or $psfzfMissing) -and -not $InstallMissingTools) { + $names = @($missing | ForEach-Object { $_.Name }) + @(if ($psfzfMissing) { 'PSFzf (module)' }) + Write-PspHost ("Not installed: {0}. Run 'Update-Profile -InstallMissingTools' to install them (winget accepts the source and package agreements for you)." -f ($names -join ', ')) -ForegroundColor DarkYellow + } + elseif ($InstallMissingTools -and (Get-Command winget -ErrorAction Ignore)) { + $installedTools = @() + foreach ($tool in $missing) { + if ($PSCmdlet.ShouldProcess($tool.Name, "Install via winget (accepts the winget source and package agreements)")) { + Write-PspHost " Installing $($tool.Name)..." -ForegroundColor Yellow + winget install -e --id $tool.Id --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq -1978335185 -or $LASTEXITCODE -eq -1978335189) { + Write-PspHost " $($tool.Name) installed." -ForegroundColor Green + $installedTools += $tool } - } - # Refresh PATH so newly installed tools are found - if ($installedTools.Count -gt 0) { - Update-SessionPathFromRegistry - } - # PSFzf module (required for fzf integration) - if ((Get-Command fzf -ErrorAction Ignore) -and -not (Get-Module -ListAvailable -Name PSFzf)) { - if ($PSCmdlet.ShouldProcess('PSFzf', 'Install PowerShell module')) { - try { - Install-Module -Name PSFzf -Scope CurrentUser -Force -AllowClobber - Write-Host " PSFzf module installed." -ForegroundColor Green - } - catch { Write-Warning " Failed to install PSFzf: $_" } + else { + Write-Warning " $($tool.Name) install may have failed (exit code: $LASTEXITCODE)" } } - # Invalidate init caches only for tools that were actually installed - foreach ($tool in $installedTools) { - if ($tool.Cache) { - Remove-Item (Join-Path $cacheDir $tool.Cache) -ErrorAction SilentlyContinue + } + # Refresh PATH so newly installed tools are found + if ($installedTools.Count -gt 0) { + Update-SessionPathFromRegistry + } + if ((Get-Command fzf -ErrorAction Ignore) -and $psfzfMissing) { + $psfzfVersion = (Get-PspComponentPins).PSFzf + if ($PSCmdlet.ShouldProcess("PSFzf $psfzfVersion", 'Install PowerShell module from the PowerShell Gallery')) { + try { + Install-Module -Name PSFzf -RequiredVersion $psfzfVersion -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + Write-PspHost " PSFzf $psfzfVersion installed." -ForegroundColor Green } + catch { Write-Warning " Failed to install PSFzf: $_" } + } + } + # Invalidate init caches only for tools that were actually installed + foreach ($tool in $installedTools) { + if ($tool.Cache) { + Remove-Item -LiteralPath (Join-Path $cacheDir $tool.Cache) -ErrorAction SilentlyContinue } } } + elseif ($InstallMissingTools) { + Write-Warning "winget not found; cannot install missing tools." + } # Save configs to cache. if ($configChanged) { if ($PSCmdlet.ShouldProcess($cachedConfig, "Save theme.json to cache")) { - Copy-Item -Path $tempConfig -Destination $cachedConfig -Force + Copy-Item -LiteralPath $tempConfig -Destination $cachedConfig -Force } } if ($terminalConfigChanged) { if ($PSCmdlet.ShouldProcess($cachedTerminalConfig, "Save terminal-config.json to cache")) { - Copy-Item -Path $tempTerminalConfig -Destination $cachedTerminalConfig -Force + Copy-Item -LiteralPath $tempTerminalConfig -Destination $cachedTerminalConfig -Force } } - if (Test-Path $userSettingsPath) { + if (Test-Path -LiteralPath $userSettingsPath) { if ($userSettingsParsed -and $userSettingsHash -and $phaseErrors.Count -eq 0) { if ($PSCmdlet.ShouldProcess($userSettingsStatePath, 'Save applied user-settings fingerprint')) { - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($userSettingsStatePath, $userSettingsHash, $utf8NoBom) + Write-Utf8FileAtomic -Path $userSettingsStatePath -Content $userSettingsHash } } } - elseif (Test-Path $userSettingsStatePath) { + elseif (Test-Path -LiteralPath $userSettingsStatePath) { if ($PSCmdlet.ShouldProcess($userSettingsStatePath, 'Remove stale user-settings fingerprint')) { - Remove-Item $userSettingsStatePath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $userSettingsStatePath -Force -ErrorAction SilentlyContinue } } # Error summary if ($phaseErrors.Count -gt 0) { - Write-Host "" - Write-Host "Update completed with $($phaseErrors.Count) issue(s):" -ForegroundColor Yellow + Write-PspHost "" + Write-PspHost "Update completed with $($phaseErrors.Count) issue(s):" -ForegroundColor Yellow foreach ($err in $phaseErrors) { - Write-Host " - $err" -ForegroundColor Yellow + Write-PspHost " - $err" -ForegroundColor Yellow } } - # Refresh the best-effort update-check baseline after applying the profile. - if ($profileActuallyUpdated) { - $_upSha = Get-LatestMainCommitSha - if ($_upSha) { - try { Write-Utf8FileAtomic (Join-Path $cacheDir 'applied-commit.sha') $_upSha } catch { $null = $_ } + # Record exactly what was applied: the update-check baseline and psp-doctor read it. + if ($profileActuallyUpdated -and -not $WhatIfPreference) { + $newVersion = [regex]::Match((Get-Utf8FileText $tempProfile), "(?m)^\s*Version\s*=\s*'([^']+)'").Groups[1].Value + try { + Set-PspInstallState -CacheDir $cacheDir -Version $newVersion -Ref $Ref -Commit $commit -BundleSha256 $combinedHash -Source 'remote' + if ($commit) { Write-Utf8FileAtomic -Path (Join-Path $cacheDir 'applied-commit.sha') -Content $commit } } + catch { Write-Verbose "Could not record install state: $_" } } # Restart when any profile or configuration content loaded by the session changed. @@ -1387,7 +2048,7 @@ function Update-Profile { elseif ($terminalConfigChanged) { 'Terminal config updated' } else { 'User settings updated' } # Clean up temp files here too: Restart-TerminalToApply calls exit, which can bypass the finally block. - Remove-Item $tempProfile, $tempConfig, $tempTerminalConfig -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempProfile, $tempConfig, $tempTerminalConfig -ErrorAction SilentlyContinue Restart-TerminalToApply -Message "$reason. Restarting terminal..." } } @@ -1396,23 +2057,27 @@ function Update-Profile { Write-Error "Unable to check for `$profile updates: $_" } finally { - Remove-Item $tempProfile -ErrorAction SilentlyContinue - Remove-Item $tempConfig -ErrorAction SilentlyContinue - Remove-Item $tempTerminalConfig -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempProfile -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempConfig -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tempTerminalConfig -ErrorAction SilentlyContinue } } # Check for new PowerShell (Core) releases and update via winget function Update-PowerShell { + <# + .SYNOPSIS + Check for new PowerShell releases. + #> [CmdletBinding(SupportsShouldProcess)] param() if ($PSVersionTable.PSEdition -ne "Core") { - Write-Host "Windows PowerShell 5.1 is updated via Windows Update, not winget." -ForegroundColor Yellow - Write-Host "This command checks for PowerShell 7+ (Core) updates only." -ForegroundColor Yellow + Write-PspHost "Windows PowerShell 5.1 is updated via Windows Update, not winget." -ForegroundColor Yellow + Write-PspHost "This command checks for PowerShell 7+ (Core) updates only." -ForegroundColor Yellow return } try { - Write-Host "Checking for PowerShell updates..." -ForegroundColor Cyan + Write-PspHost "Checking for PowerShell updates..." -ForegroundColor Cyan $currentVersion = $PSVersionTable.PSVersion $gitHubApiUrl = "https://api.github.com/repos/PowerShell/PowerShell/releases/latest" $headers = @{} @@ -1423,13 +2088,13 @@ function Update-PowerShell { $latestVersion = [version]$latestVersionStr if ($currentVersion -lt $latestVersion) { if ($PSCmdlet.ShouldProcess("PowerShell (Core) $currentVersion -> $latestVersion", 'winget upgrade')) { - Write-Host "Updating PowerShell ($currentVersion -> $latestVersion)..." -ForegroundColor Yellow - Start-Process pwsh.exe -ArgumentList "-NoProfile -Command winget upgrade Microsoft.PowerShell --accept-source-agreements --accept-package-agreements" -NoNewWindow - Write-Host "PowerShell update started. Please restart your shell when complete." -ForegroundColor Magenta + Write-PspHost "Updating PowerShell ($currentVersion -> $latestVersion)..." -ForegroundColor Yellow + Start-Process -FilePath (Get-PspShellPath) -ArgumentList "-NoProfile -Command winget upgrade Microsoft.PowerShell --accept-source-agreements --accept-package-agreements" -NoNewWindow + Write-PspHost "PowerShell update started. Please restart your shell when complete." -ForegroundColor Magenta } } else { - Write-Host "Your PowerShell is up to date." -ForegroundColor Green + Write-PspHost "Your PowerShell is up to date." -ForegroundColor Green } } catch { @@ -1447,6 +2112,10 @@ function Update-PowerShell { } # Update installed profile tools via winget, while preserving direct/MSI Oh My Posh installs. function Update-Tools { + <# + .SYNOPSIS + Upgrade winget-managed tools. + #> [CmdletBinding(SupportsShouldProcess)] param() if (-not (Get-Command winget -ErrorAction Ignore)) { @@ -1465,7 +2134,7 @@ function Update-Tools { } if (-not $installed) { - Write-Host "No profile tools detected. Run Update-Profile to install them." -ForegroundColor Yellow + Write-PspHost "No profile tools detected. Run Update-Profile to install them." -ForegroundColor Yellow return } $upgraded = 0 @@ -1478,7 +2147,7 @@ function Update-Tools { if ($tool.UpgradeStrategy -eq 'preserve-direct' -and $tool.Cmd -eq 'oh-my-posh') { $ompInstall = Get-OhMyPoshInstallInfo if ($ompInstall.InstallKind -eq 'direct') { - Write-Host "Skipping $($tool.Name) update to preserve direct/MSI install at $($ompInstall.Path)." -ForegroundColor DarkGray + Write-PspHost "Skipping $($tool.Name) update to preserve direct/MSI install at $($ompInstall.Path)." -ForegroundColor DarkGray $preserved++ continue } @@ -1487,7 +2156,7 @@ function Update-Tools { if (-not $PSCmdlet.ShouldProcess($tool.Name, 'winget upgrade')) { continue } # Capture pre-upgrade version $oldVer = Get-ProfileToolVersionText -Tool $tool -ExecutablePath $toolPath - Write-Host "Updating $($tool.Name)..." -ForegroundColor Cyan + Write-PspHost "Updating $($tool.Name)..." -ForegroundColor Cyan winget upgrade --id $tool.Id --accept-source-agreements --accept-package-agreements if ($LASTEXITCODE -eq 0) { # Refresh PATH after install. @@ -1495,14 +2164,14 @@ function Update-Tools { $newToolPath = Get-ProfileToolExecutablePath -Tool $tool $newVer = if ($newToolPath) { Get-ProfileToolVersionText -Tool $tool -ExecutablePath $newToolPath } else { $null } if ($newVer -and $oldVer -and $newVer -ne $oldVer) { - Write-Host " $($tool.Name): $oldVer -> $newVer" -ForegroundColor Green + Write-PspHost " $($tool.Name): $oldVer -> $newVer" -ForegroundColor Green if ($tool.Cache) { - Remove-Item (Join-Path $cacheDir $tool.Cache) -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path (Get-PspCacheDir) $tool.Cache) -ErrorAction SilentlyContinue } $upgraded++ } else { - Write-Host " $($tool.Name): already up to date ($oldVer)" -ForegroundColor DarkGray + Write-PspHost " $($tool.Name): already up to date ($oldVer)" -ForegroundColor DarkGray } } elseif ($LASTEXITCODE -ne -1978335189) { $failed++ } @@ -1515,114 +2184,129 @@ function Update-Tools { } if ($upgraded -eq 0 -and $failed -eq 0) { if ($preserved -gt 0) { - Write-Host "All winget-managed tools are up to date. $preserved tool(s) were preserved." -ForegroundColor Green + Write-PspHost "All winget-managed tools are up to date. $preserved tool(s) were preserved." -ForegroundColor Green } else { - Write-Host "All tools are up to date." -ForegroundColor Green + Write-PspHost "All tools are up to date." -ForegroundColor Green } } } +# Return the executable of the running PowerShell by absolute path; Store installs, which cannot be started by path, use their app alias. +function Get-PspShellPath { + $exe = (Get-Process -Id $PID).Path + if ($exe -and $exe -notmatch '\\WindowsApps\\') { return $exe } + if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh.exe' } else { 'powershell.exe' } +} + # Restart interactively in Windows Terminal when available and fall back to pwsh or powershell. function Restart-TerminalToApply { param([string]$Message = "Update applied. Restarting terminal...") - if (-not $isInteractive) { return } + if (-not $script:PSP.IsInteractive) { return } if ($WhatIfPreference) { return } $dir = (Get-Location).Path if (-not $dir -or -not (Test-Path -LiteralPath $dir -PathType Container -ErrorAction SilentlyContinue)) { $dir = [Environment]::GetFolderPath('UserProfile') } - $shellName = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh" } else { "powershell" } - Write-Host $Message -ForegroundColor Green + $shellExe = Get-PspShellPath + Write-PspHost $Message -ForegroundColor Green Start-Sleep -Seconds 2 - Write-Host "Press Enter to restart (or close this window to cancel)..." -ForegroundColor Yellow + Write-PspHost "Press Enter to restart (or close this window to cancel)..." -ForegroundColor Yellow try { $null = Read-Host } catch { $null = $_ } - $wt = Get-Command wt.exe -ErrorAction Ignore + $wt = Get-Command wt.exe -CommandType Application -ErrorAction Ignore | Select-Object -First 1 if ($wt) { - Start-Process -FilePath "wt.exe" -ArgumentList "-w", "0", "-d", $dir, $shellName, "-NoExit" + Start-Process -FilePath $wt.Source -ArgumentList (ConvertTo-NativeArgumentLine @('-w', '0', '-d', $dir, $shellExe, '-NoExit')) } else { - $shellExe = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh.exe" } else { "powershell.exe" } Start-Process -FilePath $shellExe -ArgumentList "-NoExit" -WorkingDirectory $dir } exit } -# Clear user temp/browser caches (-IncludeSystemCaches for system dirs) +# Clear the user temp folder and the legacy WinINet (Internet Explorer) cache; -IncludeSystemCaches adds Windows\Temp and Windows\Prefetch. function Clear-Cache { - [CmdletBinding(SupportsShouldProcess = $true)] + <# + .SYNOPSIS + Clear user/system temp caches. + .EXAMPLE + Clear-Cache -WhatIf + Show which folders would be emptied. + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [switch]$IncludeSystemCaches ) - Write-Host "Clearing cache..." -ForegroundColor Cyan + # Folders a misconfigured TEMP could point at; deleting their contents would be catastrophic. + $neverClear = @( + [Environment]::GetFolderPath('UserProfile'), [Environment]::GetFolderPath('MyDocuments'), [Environment]::GetFolderPath('Desktop'), + $env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA, $env:APPDATA + ) | Where-Object { $_ } | ForEach-Object { [System.IO.Path]::GetFullPath($_).TrimEnd('\') } - # Build cleanup paths only from existing base directories. $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) { - # Require confirmation before removing system-wide caches. - if (-not $WhatIfPreference -and $ConfirmPreference -ne 'None') { - Write-Host '' - Write-Host 'You are about to clear SYSTEM caches (Windows\Temp, Windows\Prefetch).' -ForegroundColor Yellow - Write-Host 'These paths affect every user on this machine and require admin.' -ForegroundColor Yellow - $reply = Read-Host ' Continue? [y/N]' - if ($reply -notmatch '^(?i:y|yes)$') { - Write-Host 'Cancelled. User caches left untouched as well.' -ForegroundColor DarkGray - return - } - } - if (-not [string]::IsNullOrWhiteSpace($env:SystemRoot)) { - $bases += @{ Name = "Windows Temp"; Base = (Join-Path $env:SystemRoot 'Temp'); Recurse = $true } - $bases += @{ Name = "Windows Prefetch"; Base = (Join-Path $env:SystemRoot 'Prefetch'); Recurse = $false } - } + $userTemp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + $isDriveRoot = $userTemp -eq ([System.IO.Path]::GetPathRoot($userTemp)).TrimEnd('\') + # A temp folder is named Temp or Tmp; Remote Desktop sessions use a numbered folder inside it (Temp\2). + $tempLeaf = Split-Path -Leaf $userTemp + $looksLikeTemp = $tempLeaf -match '^(?i)te?mp$' -or ($tempLeaf -match '^\d+$' -and (Split-Path -Leaf (Split-Path -Parent $userTemp)) -match '^(?i)te?mp$') + if ($isDriveRoot -or $neverClear -contains $userTemp -or -not $looksLikeTemp) { + Write-Warning "Skipping the user temp folder: '$userTemp' does not look like a temp folder (check `$env:TEMP and `$env:TMP)." + } + else { $bases += @{ Name = 'User Temp'; Base = $userTemp; Recurse = $true } } + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { $bases += @{ Name = 'Legacy Internet Explorer (WinINet) cache'; Base = (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\INetCache'); Recurse = $true } } + if ($IncludeSystemCaches -and -not [string]::IsNullOrWhiteSpace($env:SystemRoot)) { + $bases += @{ Name = 'Windows Temp (all users, needs admin)'; Base = (Join-Path $env:SystemRoot 'Temp'); Recurse = $true } + $bases += @{ Name = 'Windows Prefetch (all users, needs admin)'; 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 - if ($target.Recurse) { - Remove-Item -Path $target.Path -Recurse -Force -ErrorAction SilentlyContinue - } - else { - Remove-Item -Path $target.Path -Force -ErrorAction SilentlyContinue - } - Write-Host "Cleared $($target.Name)." -ForegroundColor Green + foreach ($target in $bases) { + if (-not (Test-Path -LiteralPath $target.Base -PathType Container)) { continue } + if (-not $PSCmdlet.ShouldProcess($target.Base, "Delete the contents of $($target.Name)")) { continue } + $removed = 0 + $skipped = 0 + foreach ($child in @(Get-ChildItem -LiteralPath $target.Base -Force -ErrorAction SilentlyContinue)) { + if (-not $target.Recurse -and $child.PSIsContainer) { continue } + # Keep going past files in use; whatever is still there afterwards counts as skipped. + Remove-Item -LiteralPath $child.FullName -Recurse:$target.Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $child.FullName) { $skipped++ } else { $removed++ } } + Write-PspHost ("Cleared {0}: {1} item(s) removed, {2} in use or protected." -f $target.Name, $removed, $skipped) -ForegroundColor Green } } # Show the last command's execution time from bounded PowerShell history. function duration { + <# + .SYNOPSIS + Show elapsed time of the last command. + #> $last = Get-History -Count 1 -ErrorAction SilentlyContinue if (-not $last) { - Write-Host 'No history yet.' -ForegroundColor Yellow + Write-PspHost '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 + Write-PspHost (" {0}" -f $cmd) -ForegroundColor DarkGray + Write-PspHost (" {0}s ({1:hh\:mm\:ss\.fff})" -f $secs, $span) -ForegroundColor Cyan } # Move back through the bounded directory history without re-adding consumed entries. function cdb { + <# + .SYNOPSIS + Cd back N entries in history (default 1). + #> [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 + Write-PspHost '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 + Write-PspHost ("History has {0} entries; N must be 1..{0}." -f $script:PSP.PwdHistory.Count) -ForegroundColor Yellow return } $target = $script:PSP.PwdHistory[$N - 1] @@ -1639,8 +2323,12 @@ function cdb { # List directory history entries addressable through cdb. function cdh { + <# + .SYNOPSIS + List the cd history stack. + #> if (-not $script:PSP -or -not $script:PSP.PwdHistory -or $script:PSP.PwdHistory.Count -eq 0) { - Write-Host 'No directory history yet.' -ForegroundColor Yellow + Write-PspHost 'No directory history yet.' -ForegroundColor Yellow return } $i = 1 @@ -1648,13 +2336,53 @@ function cdh { $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 + Write-PspHost (" [{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 { "" } +# Return a directory's .psprc.ps1 as its SHA-256 and text, read once so the hash describes exactly what runs ($null when it has none). +function Get-PspRcContent { + param([Parameter(Mandatory)][string]$Directory) + $rc = Join-Path $Directory '.psprc.ps1' + if (-not (Test-Path -LiteralPath $rc -PathType Leaf)) { return $null } + $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $rc).ProviderPath) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { $hash = [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '') } + finally { $sha.Dispose() } + $text = '' + if ($bytes.Length -gt 0) { + $encoding = Get-PspTextEncoding -Bytes $bytes + $skip = $encoding.GetPreamble().Length + $text = $encoding.GetString($bytes, $skip, $bytes.Length - $skip) + } + [PSCustomObject]@{ Hash = $hash; Text = $text } +} + +# Return the SHA-256 of a directory's .psprc.ps1, or '' when it has none. +function Get-PspRcHash { + param([Parameter(Mandatory)][string]$Directory) + $content = Get-PspRcContent -Directory $Directory + if ($content) { $content.Hash } else { '' } +} + +# Return a script block for approved .psprc.ps1 text that sees $PSScriptRoot and $PSCommandPath as a dot-sourced file would. +function New-PspRcScriptBlock { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][string]$Path) + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($Text, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { throw "$Path does not parse: $($parseErrors[0].Message)" } + # The engine clears both variables for in-memory code, so set them after any param block or using statements (which + # must come first), on the same line so error line numbers still match the file. + $offset = 0 + if ($ast.ParamBlock) { $offset = $ast.ParamBlock.Extent.EndOffset } + foreach ($statement in $ast.UsingStatements) { $offset = [Math]::Max($offset, $statement.Extent.EndOffset) } + $prelude = "`$PSScriptRoot = '{0}'; `$PSCommandPath = '{1}'; " -f ((Split-Path -Parent $Path) -replace "'", "''"), ($Path -replace "'", "''") + if ($offset -gt 0) { $prelude = "; $prelude" } + [scriptblock]::Create($Text.Insert($offset, $prelude)) +} + # Run shared prompt hooks and trusted per-directory profiles for both prompt implementations. function Invoke-PromptStage { if (-not $script:PSP) { return } @@ -1677,16 +2405,24 @@ function Invoke-PromptStage { 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)" - } + if (-not (Test-Path -LiteralPath $rc -PathType Leaf)) { return } + $trusted = $script:PSP.TrustedDirs | Where-Object { $_ -ieq $current } | Select-Object -First 1 + if (-not $trusted) { + Write-PspHost ".psprc.ps1 found in this directory. Review it, then run Add-TrustedDirectory to auto-load it." -ForegroundColor Yellow + return } - else { - Write-Host ".psprc.ps1 found in this directory. Run Add-TrustedDirectory to auto-load it." -ForegroundColor Yellow + # Trust covers the file content that was approved (like direnv): any later change, for example from git pull, needs a new approval. + $approved = [string]$script:PSP.TrustedRcHashes[$trusted] + $rcContent = Get-PspRcContent -Directory $current + if (-not $approved -or -not $rcContent -or $approved -ne $rcContent.Hash) { + Write-Warning ".psprc.ps1 in $current was not run: it changed since it was approved, or was never approved. Review it, then run Add-TrustedDirectory to approve this version." + return + } + # Run the text that was hashed, so the file cannot change between the check and the run. + try { . (New-PspRcScriptBlock -Text $rcContent.Text -Path $rc) } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning ".psprc.ps1 failed: $($_.Exception.Message)" } } catch { @@ -1695,12 +2431,17 @@ function Invoke-PromptStage { } } +# Return the plain prompt text, also used when oh-my-posh fails or times out. +function Get-PspPlainPrompt { + if ($script:PSP.IsAdmin) { "[" + (Get-Location) + "] # " } else { "[" + (Get-Location) + "] $ " } +} + function prompt { Invoke-PromptStage - if ($adminSuffix) { "[" + (Get-Location) + "] # " } else { "[" + (Get-Location) + "] $ " } + Get-PspPlainPrompt } -$script:FallbackPromptFunction = $Function:prompt -$Host.UI.RawUI.WindowTitle = "PowerShell {0}$adminSuffix" -f $PSVersionTable.PSVersion.ToString() + +$Host.UI.RawUI.WindowTitle = "PowerShell {0}{1}" -f $PSVersionTable.PSVersion.ToString(), $script:PSP.AdminSuffix # Resolve the editor lazily from the customizable $script:EditorPriority list. if ($null -eq $script:EditorPriority) { @@ -1743,18 +2484,30 @@ function Resolve-PreferredEditor { # Open files with preferred editor (alias: edit) function edit { + <# + .SYNOPSIS + Open file in preferred editor. + #> $editor = Resolve-PreferredEditor & $editor @script:ResolvedEditorArgs @args } # Quick Access to Editing the Profile function Edit-Profile { + <# + .SYNOPSIS + Open profile in preferred editor. + #> edit $PROFILE } Set-Alias -Name ep -Value Edit-Profile # Create file or update its timestamp function touch { + <# + .SYNOPSIS + Create file or update timestamp. + #> param([Parameter(ValueFromRemainingArguments)][string[]]$Files) if (-not $Files) { Write-Error "Usage: touch [file2 ...]"; return } foreach ($file in $Files) { @@ -1768,6 +2521,10 @@ function touch { } # Recursive file search by name function ff($name) { + <# + .SYNOPSIS + Find files recursively. + #> Get-ChildItem -recurse -filter "*${name}*" -ErrorAction SilentlyContinue | ForEach-Object { Write-Output "$($_.FullName)" } @@ -1775,6 +2532,10 @@ function ff($name) { # Network Utilities function pubip { + <# + .SYNOPSIS + Public IP. + #> try { (Invoke-WebRequest https://ifconfig.me/ip -TimeoutSec 10 -UseBasicParsing).Content } @@ -1784,8 +2545,18 @@ function pubip { } } -# Download WinUtil from https://christitus.com/win to temp; requires a hash pin or -Force plus ShouldProcess. +# Download WinUtil from https://christitus.com/win into memory; it runs only with a hash pin or -Force, after confirmation. function winutil { + <# + .SYNOPSIS + Fetch Chris Titus WinUtil (safe-by-default; -ExpectedSha256/-Force to run). + .EXAMPLE + winutil + Download WinUtil and print its SHA-256 without running it. + .EXAMPLE + winutil -ExpectedSha256 + Run it only if it still has that hash. + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [ValidatePattern('^[A-Fa-f0-9]{64}$')] @@ -1796,16 +2567,24 @@ function winutil { Write-Error 'Use either -ExpectedSha256 or -Force, not both.' return } - $scriptPath = Join-Path $env:TEMP ("winutil-" + [System.IO.Path]::GetRandomFileName() + ".ps1") + if (($ExpectedSha256 -or $Force) -and -not $script:PSP.IsAdmin) { + # Run unelevated, WinUtil relaunches itself as administrator from its latest release, which the hash check never saw. + Write-Error 'Run winutil from an elevated shell (open one with: admin). Unelevated, WinUtil relaunches itself as administrator from its latest, unchecked release.' + return + } try { - 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 + # The bytes stay in memory between hashing and execution, so nothing on disk can be swapped in after the check. + $response = Invoke-WebRequest -Uri 'https://christitus.com/win' -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop + $bytes = $response.RawContentStream.ToArray() + if ($bytes.Length -eq 0) { throw 'Downloaded WinUtil script is empty.' } + $sha = [System.Security.Cryptography.SHA256]::Create() + try { $actualHash = [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '') } + finally { $sha.Dispose() } + Write-PspHost "Source: https://christitus.com/win" + Write-PspHost "SHA256: $actualHash" -ForegroundColor Cyan $downloadLabel = "WinUtil script from https://christitus.com/win (SHA256: $actualHash)" + $offset = if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { 3 } else { 0 } + $code = [System.Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset) if ($ExpectedSha256) { if ($actualHash -ine $ExpectedSha256.Trim()) { Write-Error "SHA256 mismatch. Expected: $ExpectedSha256. Actual: $actualHash. Aborting." @@ -1814,8 +2593,8 @@ function winutil { if (-not $PSCmdlet.ShouldProcess($downloadLabel, 'Execute downloaded WinUtil script (hash matched)')) { return } - Write-Host "SHA256 matched expected value. Executing..." -ForegroundColor Green - & $scriptPath + Write-PspHost "SHA256 matched expected value. Executing..." -ForegroundColor Green + & ([scriptblock]::Create($code)) return } if ($Force) { @@ -1823,26 +2602,27 @@ function winutil { if (-not $PSCmdlet.ShouldProcess($downloadLabel, 'Execute downloaded WinUtil script without hash verification')) { return } - Write-Host "Executing with -Force (no hash verification)..." -ForegroundColor Yellow - & $scriptPath + Write-PspHost "Executing with -Force (no hash verification)..." -ForegroundColor Yellow + & ([scriptblock]::Create($code)) 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)" + Write-PspHost '' + Write-PspHost "NOT executing by default. Re-run with one of:" -ForegroundColor Yellow + Write-PspHost " winutil -ExpectedSha256 '$actualHash' (pin this version)" + Write-PspHost " winutil -Force (trust without hash check)" } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } Write-Error "Failed to fetch WinUtil: $($_.Exception.Message)" } - finally { - Remove-Item $scriptPath -Force -ErrorAction SilentlyContinue - } } # Launch Harden Windows Security with explicit ShouldProcess confirmation. function harden { + <# + .SYNOPSIS + Open Harden Windows Security (prompts before launch). + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param() $hssPath = Get-ExternalCommandPath -CommandName 'hss.exe' @@ -1856,23 +2636,27 @@ function harden { } } -# Open an elevated Windows Terminal, pwsh, or powershell session. +# Open an elevated Windows Terminal, pwsh, or powershell session (executables resolved to absolute paths before elevating). function admin { - $shell = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh.exe" } else { "powershell.exe" } - $hasWt = [bool](Get-Command wt -ErrorAction Ignore) + <# + .SYNOPSIS + Open elevated terminal. + #> + $shell = Get-PspShellPath + $wt = Get-Command wt.exe -CommandType Application -ErrorAction Ignore | Select-Object -First 1 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)) - if ($hasWt) { - Start-Process wt -Verb runAs -ArgumentList "$shell -NoExit -EncodedCommand $encoded" + if ($wt) { + Start-Process -FilePath $wt.Source -Verb runAs -ArgumentList (ConvertTo-NativeArgumentLine @($shell, '-NoExit', '-EncodedCommand', $encoded)) } else { Start-Process -FilePath $shell -Verb runAs -ArgumentList @('-NoExit', '-EncodedCommand', $encoded) } } - elseif ($hasWt) { - Start-Process wt -Verb runAs + elseif ($wt) { + Start-Process -FilePath $wt.Source -Verb runAs } else { Start-Process -FilePath $shell -Verb runAs @@ -1882,6 +2666,10 @@ function admin { Set-Alias -Name su -Value admin # Return system uptime through WMI on PS5 or Get-Uptime on PS7. function Get-SystemBootTime { + <# + .SYNOPSIS + Return system uptime through WMI on PS5 or Get-Uptime on PS7. + #> if ($PSVersionTable.PSVersion.Major -eq 5) { $lastBoot = (Get-WmiObject win32_operatingsystem | Select-Object -First 1).LastBootUpTime [System.Management.ManagementDateTimeConverter]::ToDateTime($lastBoot) @@ -1893,36 +2681,66 @@ function Get-SystemBootTime { # Display system boot time and uptime function uptime { + <# + .SYNOPSIS + System uptime. + #> try { $bootTime = Get-SystemBootTime $formattedBootTime = $bootTime.ToString("dddd, MMMM dd, yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture) - Write-Host "System started on: $formattedBootTime" -ForegroundColor DarkGray + Write-PspHost "System started on: $formattedBootTime" -ForegroundColor DarkGray $uptime = (Get-Date) - $bootTime - Write-Host ("Uptime: {0} days, {1} hours, {2} minutes, {3} seconds" -f $uptime.Days, $uptime.Hours, $uptime.Minutes, $uptime.Seconds) -ForegroundColor Blue + Write-PspHost ("Uptime: {0} days, {1} hours, {2} minutes, {3} seconds" -f $uptime.Days, $uptime.Hours, $uptime.Minutes, $uptime.Seconds) -ForegroundColor Blue } catch { Write-Error "An error occurred while retrieving system uptime." } } -# Universal archive extractor (.zip, .tar, .gz, .7z, .rar) +# Universal archive extractor (.zip, .tar, .gz, .7z, .rar); refuses to overwrite existing files unless -Force. function extract { - param([Parameter(Mandatory)][string]$File) + <# + .SYNOPSIS + Universal archive extractor. + .EXAMPLE + extract .\archive.zip + Extract into the current directory; stops if a file would be overwritten. + .EXAMPLE + extract .\archive.zip -Force + Extract and overwrite existing files. + #> + param([Parameter(Mandatory)][string]$File, [switch]$Force) $resolved = Resolve-Path -LiteralPath $File -ErrorAction SilentlyContinue if (-not $resolved) { Write-Error "File not found: $File"; return } - $path = $resolved.Path + $path = $resolved.ProviderPath + $destination = (Get-Location).ProviderPath $ext = [System.IO.Path]::GetExtension($path).ToLower() $baseName = [System.IO.Path]::GetFileNameWithoutExtension($path) if ([string]::Equals('.tar', [System.IO.Path]::GetExtension($baseName), [StringComparison]::OrdinalIgnoreCase)) { $ext = '.tar' + $ext } - Write-Host "Extracting $path ..." -ForegroundColor Cyan + # List what the archive would write and stop before touching anything that already exists. + $entries = @() + if ($ext -eq '.zip') { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($path) + try { $entries = @($zip.Entries | ForEach-Object { $_.FullName }) } finally { $zip.Dispose() } + } + elseif ($ext -in '.tar', '.tar.gz', '.tgz', '.tar.bz2') { $entries = @(tar -tf "$path" 2>$null) } + elseif ($ext -eq '.gz') { $entries = @($baseName) } + $conflicts = @($entries | Where-Object { $_ -and -not $_.EndsWith('/') } | + ForEach-Object { Join-Path $destination ($_ -replace '/', '\') } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }) + if ($conflicts.Count -gt 0 -and -not $Force) { + Write-Error ("{0} file(s) already exist, for example '{1}'. Nothing was extracted; re-run with -Force to overwrite." -f $conflicts.Count, $conflicts[0]) + return + } + Write-PspHost "Extracting $path ..." -ForegroundColor Cyan switch ($ext) { - '.zip' { Expand-Archive -LiteralPath $path -DestinationPath $pwd -Force } - '.tar' { tar -xf "$path" -C "$pwd"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } - '.tar.gz' { tar -xzf "$path" -C "$pwd"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } - '.tgz' { tar -xzf "$path" -C "$pwd"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } - '.tar.bz2' { tar -xjf "$path" -C "$pwd"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } + '.zip' { Expand-Archive -LiteralPath $path -DestinationPath $destination -Force } + '.tar' { tar -xf "$path" -C "$destination"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } + '.tar.gz' { tar -xzf "$path" -C "$destination"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } + '.tgz' { tar -xzf "$path" -C "$destination"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } + '.tar.bz2' { tar -xjf "$path" -C "$destination"; if ($LASTEXITCODE -ne 0) { Write-Error "tar extraction failed (exit $LASTEXITCODE)" } } '.gz' { - $outFile = Join-Path $pwd $baseName + $outFile = Join-Path $destination $baseName $in = [System.IO.File]::OpenRead($path) try { $gz = New-Object System.IO.Compression.GZipStream($in, [System.IO.Compression.CompressionMode]::Decompress) @@ -1934,15 +2752,12 @@ function extract { finally { $gz.Dispose() } } finally { $in.Dispose() } - Write-Host "Extracted to $outFile" -ForegroundColor Green - } - '.7z' { - if (-not (Get-Command 7z -ErrorAction Ignore)) { Write-Error "7z not found. Install with: winget install 7zip.7zip"; return } - 7z x "$path" -o"$pwd" + Write-PspHost "Extracted to $outFile" -ForegroundColor Green } - '.rar' { + { $_ -in '.7z', '.rar' } { if (-not (Get-Command 7z -ErrorAction Ignore)) { Write-Error "7z not found. Install with: winget install 7zip.7zip"; return } - 7z x "$path" -o"$pwd" + # 7-Zip asks before overwriting by default; -Force overwrites without asking. + if ($Force) { 7z x "$path" -o"$destination" -aoa } else { 7z x "$path" -o"$destination" } } default { Write-Error "Unsupported format: $ext" } } @@ -1950,12 +2765,16 @@ function extract { # Upload an entire file to a public paste after confirmation. function hb { + <# + .SYNOPSIS + Upload to hastebin. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param([Parameter(Position = 0)][string]$Path, [switch]$Force) if (-not $Path) { Write-Error "No file path specified."; return } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Write-Error "File path does not exist."; return } - $Content = Get-Content -LiteralPath $Path -Raw + $Content = Get-Utf8FileText $Path $uri = "https://bin.christitus.com/documents" $sizeKb = [math]::Round((Get-Item -LiteralPath $Path).Length / 1KB, 1) @@ -1975,28 +2794,58 @@ function hb { } # Grep Utility (PS5-compatible, no dependencies) function grep { + <# + .SYNOPSIS + Search for pattern in files. + #> param([string]$regex, [string]$dir) if (-not $regex) { Write-Error "Usage: grep [dir] or | grep "; return } $hasInput = $MyInvocation.ExpectingInput - if (Get-Command rg -ErrorAction Ignore) { - if ($dir) { rg $regex $dir } - elseif ($hasInput) { $input | rg $regex } - else { rg $regex . } + if (Get-Command rg -CommandType Application -ErrorAction Ignore) { + # -e keeps a pattern that starts with '-' from being read as an option. + if ($dir) { rg -e $regex -- $dir } + elseif ($hasInput) { $input | rg -e $regex } + else { rg -e $regex -- . } } else { - if ($dir) { Get-ChildItem $dir -Recurse -File | Select-String $regex } - elseif ($hasInput) { $input | Select-String $regex } - else { Get-ChildItem . -Recurse -File | Select-String $regex } + if ($dir) { Get-ChildItem -LiteralPath $dir -Recurse -File | Select-String -Pattern $regex } + elseif ($hasInput) { $input | Select-String -Pattern $regex } + else { Get-ChildItem -LiteralPath . -Recurse -File | Select-String -Pattern $regex } } } # Disk volume info function df { + <# + .SYNOPSIS + Disk volumes. + #> get-volume } -# Find and replace text in a file +# Detect a text file's encoding: byte order mark first, then strict UTF-8, otherwise the system ANSI code page. +function Get-PspTextEncoding { + param([Parameter(Mandatory)][byte[]]$Bytes) + if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { return [System.Text.UTF8Encoding]::new($true) } + if ($Bytes.Length -ge 4 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE -and $Bytes[2] -eq 0 -and $Bytes[3] -eq 0) { return [System.Text.UTF32Encoding]::new($false, $true) } + if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE) { return [System.Text.UnicodeEncoding]::new($false, $true) } + if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFE -and $Bytes[1] -eq 0xFF) { return [System.Text.UnicodeEncoding]::new($true, $true) } + try { + $null = [System.Text.UTF8Encoding]::new($false, $true).GetString($Bytes) + return [System.Text.UTF8Encoding]::new($false) + } + catch { return [System.Text.Encoding]::GetEncoding([System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage) } +} + +# Find and replace literal text in a file, keeping its encoding and byte order mark, with a .bak copy of the original. function sed($file, $find, $replace) { + <# + .SYNOPSIS + Find and replace in file. + .EXAMPLE + sed .\config.ini "old" "new" + Replace every "old" with "new", keeping the encoding; the original is saved as config.ini.bak. + #> if (-not $file -or -not (Test-Path -LiteralPath $file)) { Write-Error "File not found: $file" return @@ -2004,36 +2853,45 @@ function sed($file, $find, $replace) { if ($null -eq $find -or $find -eq '') { Write-Error "Usage: sed "; return } if ($null -eq $replace) { $replace = '' } $resolved = (Resolve-Path -LiteralPath $file).Path - # Read as UTF-8 (BOM-aware) so non-ASCII content survives the rewrite on Windows PowerShell 5.1. - $content = Get-Utf8FileText $resolved - if ($null -eq $content -or $content.Length -eq 0) { Write-Warning "File is empty: $file"; return } - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - # Keep a single .bak of the pre-edit content, like sed -i.bak. - Copy-Item -LiteralPath $resolved -Destination "$resolved.bak" -Force -ErrorAction SilentlyContinue - [System.IO.File]::WriteAllText($resolved, $content.replace("$find", $replace), $utf8NoBom) + $bytes = [System.IO.File]::ReadAllBytes($resolved) + if ($bytes.Length -eq 0) { Write-Warning "File is empty: $file"; return } + $encoding = Get-PspTextEncoding -Bytes $bytes + $bomLength = $encoding.GetPreamble().Length + $content = $encoding.GetString($bytes, $bomLength, $bytes.Length - $bomLength) + # Keep a single .bak of the pre-edit content, like sed -i.bak; no backup means no edit. + Copy-Item -LiteralPath $resolved -Destination "$resolved.bak" -Force -ErrorAction Stop + [System.IO.File]::WriteAllText($resolved, $content.replace("$find", $replace), $encoding) } # Show the full path of a command function which($name) { + <# + .SYNOPSIS + Show command path. + #> if (-not $name) { Write-Error "Usage: which "; return } $cmd = Get-Command $name -ErrorAction Ignore if ($cmd) { $cmd | Select-Object -ExpandProperty Definition; return } # Fall back to checking the current directory (like bash which for ./files) - $local = Join-Path $pwd $name - if (Test-Path $local) { (Resolve-Path $local).Path; return } + $local = Join-Path (Get-Location).ProviderPath $name + if (Test-Path -LiteralPath $local) { (Resolve-Path -LiteralPath $local).Path; return } Write-Error "which: $name not found" } # Identify file type via magic bytes (like Linux file command) function file { + <# + .SYNOPSIS + Identify file type via magic bytes. + #> param([Parameter(Mandatory)][string]$Path) $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue if (-not $resolved) { Write-Error "file: cannot open '$Path' (No such file or directory)"; return } $fullPath = $resolved.Path $item = Get-Item -LiteralPath $fullPath - if ($item.PSIsContainer) { Write-Host "$($fullPath): directory"; return } + if ($item.PSIsContainer) { Write-PspHost "$($fullPath): directory"; return } $size = $item.Length - if ($size -eq 0) { Write-Host "$($fullPath): empty"; return } + if ($size -eq 0) { Write-PspHost "$($fullPath): empty"; return } $readLen = [Math]::Min($size, 512) $stream = [System.IO.File]::OpenRead($fullPath) @@ -2121,35 +2979,65 @@ function file { else { $result = 'data' } } - Write-Host "$($fullPath): $result ($([Math]::Round($size / 1KB, 1)) KB)" + Write-PspHost "$($fullPath): $result ($([Math]::Round($size / 1KB, 1)) KB)" } # Set an environment variable in the current session function export($name, $value) { + <# + .SYNOPSIS + Set environment variable. + #> if (-not $name) { Write-Error "Usage: export "; return } if ($null -eq $value) { Write-Error "Usage: export "; return } - set-item -force -path "env:$name" -value $value; -} - -# Kill process by name -function pkill($name) { - if (-not $name) { Write-Error "Usage: pkill "; return } + Set-Item -LiteralPath "env:$name" -Value $value -Force +} + +# Kill processes by name; a wildcard pattern lists what it matches and asks first unless -Force. +function pkill { + <# + .SYNOPSIS + Kill processes by name. + .EXAMPLE + pkill notepad + Stop every notepad process. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param([Parameter(Position = 0)][string]$Name, [switch]$Force) + if (-not $Name) { Write-Error "Usage: pkill "; return } # Reject bare wildcards that could match every process. - if ($name -match '^[\*\?\.\s]+$') { Write-Error "pkill: refusing to match every process. Pass a specific name."; return } - $procs = @(Get-Process $name -ErrorAction SilentlyContinue | + if ($Name -match '^[\*\?\.\s]+$') { Write-Error "pkill: refusing to match every process. Pass a specific name."; return } + $procs = @(Get-Process -Name $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 + if (-not $procs) { Write-Warning "pkill: no killable process matching '$Name'."; return } + if ($Name -match '[\*\?\[]' -and -not $Force -and -not $WhatIfPreference) { + $names = ($procs | Select-Object -ExpandProperty ProcessName -Unique | Sort-Object) -join ', ' + $go = $false + try { $go = $PSCmdlet.ShouldContinue("Stop $($procs.Count) process(es) matching '$Name': $names", 'pkill') } + catch { Write-Warning "pkill: cannot ask for confirmation in this session; re-run with -Force to stop: $names" } + if (-not $go) { return } + } + foreach ($p in $procs) { + if ($PSCmdlet.ShouldProcess("$($p.ProcessName) (PID $($p.Id))", 'Stop process')) { $p | Stop-Process -ErrorAction SilentlyContinue } + } } # List processes by name function pgrep($name) { + <# + .SYNOPSIS + List processes by name. + #> if (-not $name) { Write-Error "Usage: pgrep "; return } Get-Process $name -ErrorAction SilentlyContinue } # Display first n lines of a file (default 10) function head { + <# + .SYNOPSIS + First n lines. + #> param($Path, $n = 10) if (-not $Path) { Write-Error "Usage: head [n]"; return } Get-Content -LiteralPath $Path -Head $n @@ -2157,6 +3045,10 @@ function head { # Display last n lines of a file (default 10, -f to follow) function tail { + <# + .SYNOPSIS + Last n lines. + #> param($Path, $n = 10, [switch]$f = $false) if (-not $Path) { Write-Error "Usage: tail [n] [-f]"; return } Get-Content -LiteralPath $Path -Tail $n -Wait:$f @@ -2165,54 +3057,70 @@ function tail { Set-Alias -Name nf -Value touch # Directory Management -function mkcd { param($dir) if (-not $dir) { Write-Error "Usage: mkcd "; return }; New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null; Set-Location $dir } - -# Move item to Recycle Bin via Shell.Application COM -function trash($path) { - if (-not $path) { Write-Error "Usage: trash "; return } - if (-not (Test-Path -LiteralPath $path)) { - Write-Host "Error: Item '$path' does not exist." +function mkcd { + <# + .SYNOPSIS + Create dir and cd into it. + #> + param($dir) if (-not $dir) { Write-Error "Usage: mkcd "; return }; $made = New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop; Set-Location -LiteralPath $made.FullName +} + +# Move a file or folder to the Recycle Bin and report what actually happened. +function trash { + <# + .SYNOPSIS + Move to Recycle Bin. + .EXAMPLE + trash .\old.txt + Move a file to the Recycle Bin. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param([Parameter(Position = 0)][string]$Path) + if (-not $Path) { Write-Error "Usage: trash "; return } + if (-not (Test-Path -LiteralPath $Path)) { Write-Error "Item '$Path' does not exist."; return } + $item = Get-Item -LiteralPath $Path -Force + $full = $item.FullName + if ($item.PSIsContainer -and -not $item.Parent) { Write-Error "Cannot move a drive root to the Recycle Bin."; return } + # Network and removable drives have no Recycle Bin; Windows then asks before deleting permanently. + $recyclable = -not $full.StartsWith('\\') + if ($recyclable) { + try { $recyclable = ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($full))).DriveType -eq [System.IO.DriveType]::Fixed } + catch { $recyclable = $false } + } + if (-not $recyclable) { Write-Warning "'$full' is on a drive without a Recycle Bin; Windows will ask before deleting it permanently." } + if (-not (Test-PspRecycleBinSession)) { + Write-Error "trash needs an interactive desktop session: in this session (SSH, WinRM or a service) Windows would delete '$full' permanently instead of recycling it. Use Remove-Item to delete it." return } - - $item = Get-Item -LiteralPath $path - - if ($item.PSIsContainer) { - if (-not $item.Parent) { Write-Error "Cannot move root directory to Recycle Bin."; return } - $parentPath = $item.Parent.FullName - } - else { - $parentPath = $item.DirectoryName - } - - $shell = New-Object -ComObject 'Shell.Application' - $folder = $null; $shellItem = $null + if (-not $PSCmdlet.ShouldProcess($full, 'Move to the Recycle Bin')) { return } + Add-Type -AssemblyName Microsoft.VisualBasic + $ui = [Microsoft.VisualBasic.FileIO.UIOption]::AllDialogs + $recycle = [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin + $onCancel = [Microsoft.VisualBasic.FileIO.UICancelOption]::ThrowException try { - $folder = $shell.NameSpace($parentPath) - if (-not $folder) { - Write-Host "Error: Cannot access parent folder '$parentPath'." - return - } - $shellItem = $folder.ParseName($item.Name) - if (-not $shellItem) { - Write-Host "Error: Cannot find '$($item.Name)' in '$parentPath'." - return - } - $shellItem.InvokeVerb('delete') - Write-Host "Item '$($item.FullName)' has been moved to the Recycle Bin." + if ($item.PSIsContainer) { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($full, $ui, $recycle, $onCancel) } + else { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($full, $ui, $recycle, $onCancel) } } - finally { - # Release every COM RCW, child first. - foreach ($com in @($shellItem, $folder, $shell)) { - if ($com) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($com) } - } + catch [System.OperationCanceledException] { + Write-Warning "Cancelled; '$full' was not moved." + return } + if (Test-Path -LiteralPath $full) { Write-Warning "'$full' is still there; it was not moved to the Recycle Bin."; return } + $where = if ($recyclable) { 'moved to the Recycle Bin' } else { 'deleted (this drive has no Recycle Bin)' } + Write-PspHost "Item '$full' has been $where." } +# Return $true when the Recycle Bin can be used: Windows recycles only in an interactive desktop session and deletes permanently otherwise. +function Test-PspRecycleBinSession { [Environment]::UserInteractive } + ### Quality of Life Aliases # Navigation Shortcuts function docs { + <# + .SYNOPSIS + Jump to Documents. + #> $docs = [Environment]::GetFolderPath("MyDocuments") if ([string]::IsNullOrWhiteSpace($docs)) { $docs = $HOME + "\Documents" } Set-Location -Path $docs @@ -2220,48 +3128,110 @@ function docs { # Change directory to Desktop function dtop { + <# + .SYNOPSIS + Jump to Desktop. + #> $dtop = [Environment]::GetFolderPath("Desktop") if ([string]::IsNullOrWhiteSpace($dtop)) { $dtop = $HOME + "\Desktop" } Set-Location -Path $dtop } # Enhanced Listing (eza - modern ls replacement with icons and git status) -if (Get-Command eza -ErrorAction Ignore) { +if (Get-Command eza -CommandType Application -ErrorAction Ignore) { # la/ll/lt expose eza under distinct names; the built-in ls alias (Get-ChildItem) is left intact # so scripts run later in this session keep the standard listing. Pass the icon setting as # --icons=WHEN: eza takes an optional value there, so a bare --icons swallows a following path. - function la { eza -a --icons=auto @args } - function ll { eza -la --icons=auto --git @args } - function lt { eza --tree --icons=auto --level=2 @args } + function la { + <# + .SYNOPSIS + Eza listing with hidden. + #> + eza -a --icons=auto @args + } + function ll { + <# + .SYNOPSIS + Eza long + git listing. + #> + eza -la --icons=auto --git @args + } + function lt { + <# + .SYNOPSIS + Eza tree listing. + #> + eza --tree --icons=auto --level=2 @args + } } else { - if ($isInteractive) { Write-Warning "eza not found. Install it with: winget install -e --id eza-community.eza" } + if ($script:PSP.IsInteractive) { Write-Warning "eza not found. Install it with: winget install -e --id eza-community.eza" } # Fallback listing when eza not installed - function la { Get-ChildItem -Force | Format-Table -AutoSize } - function ll { Get-ChildItem -Force | Format-Table Mode, LastWriteTime, Length, Name -AutoSize } - function lt { Get-ChildItem -Recurse -Depth 2 | Format-Table -AutoSize } + function la { + <# + .SYNOPSIS + Eza listing with hidden. + #> + Get-ChildItem -Force | Format-Table -AutoSize + } + function ll { + <# + .SYNOPSIS + Eza long + git listing. + #> + Get-ChildItem -Force | Format-Table Mode, LastWriteTime, Length, Name -AutoSize + } + function lt { + <# + .SYNOPSIS + Eza tree listing. + #> + Get-ChildItem -Recurse -Depth 2 | Format-Table -AutoSize + } } # Syntax-highlighted file viewer (bat - modern cat replacement) if (-not $env:BAT_THEME) { $env:BAT_THEME = "TwoDark" } -if (Get-Command bat -ErrorAction Ignore) { +if (Get-Command bat -CommandType Application -ErrorAction Ignore) { # bcat exposes bat under a distinct name; the built-in cat alias (Get-Content) is left intact so # scripts run later in this session keep reading raw file contents. - function bcat { bat --paging=never @args } + function bcat { + <# + .SYNOPSIS + Syntax-highlighted viewer (bat). + #> + bat --paging=never @args + } } else { - if ($isInteractive) { Write-Warning "bat not found. Install it with: winget install -e --id sharkdp.bat" } + if ($script:PSP.IsInteractive) { Write-Warning "bat not found. Install it with: winget install -e --id sharkdp.bat" } } # Git Shortcuts -function gs { git status } +function gs { + <# + .SYNOPSIS + Git status. + #> + git status +} # Git add all -function ga { git add .; if ($LASTEXITCODE -ne 0) { Write-Warning "git add failed (exit $LASTEXITCODE)" } } +function ga { + <# + .SYNOPSIS + Git add . + #> + git add .; if ($LASTEXITCODE -ne 0) { Write-Warning "git add failed (exit $LASTEXITCODE)" } +} # Git commit with message. Named gcmsg, not gc, so the built-in gc alias (Get-Content) stays intact # for scripts run later in this session. function gcmsg { + <# + .SYNOPSIS + Git commit -m. + #> if (-not $args) { Write-Error "Usage: gcmsg [git-flags]"; return } $msg = $args[0] $rest = @($args | Select-Object -Skip 1) @@ -2270,13 +3240,29 @@ function gcmsg { } # Git push (gp is a built-in alias, so this is named gpush) -function gpush { git push } +function gpush { + <# + .SYNOPSIS + Git push. + #> + git push +} # Git pull (gl is a built-in alias, so this is named gpull) -function gpull { git pull } +function gpull { + <# + .SYNOPSIS + Git pull. + #> + git pull +} # Jump to github directory via zoxide function g { + <# + .SYNOPSIS + Zoxide jump to github dir. + #> if (Get-Command __zoxide_z -ErrorAction Ignore) { __zoxide_z github } @@ -2286,10 +3272,20 @@ function g { } # Git clone shortcut -function gcl { git clone @args } +function gcl { + <# + .SYNOPSIS + Git clone. + #> + git clone @args +} # Add all + commit function gcom { + <# + .SYNOPSIS + Add + commit. + #> if (-not $args) { Write-Error "Usage: gcom [git-flags]"; return } git add . if ($LASTEXITCODE -ne 0) { Write-Warning "git add failed. Commit skipped."; return } @@ -2299,6 +3295,10 @@ function gcom { } # Add all + commit + push function lazyg { + <# + .SYNOPSIS + Add + commit + push. + #> if (-not $args) { Write-Error "Usage: lazyg "; return } gcom @args if ($LASTEXITCODE -eq 0) { git push } @@ -2306,16 +3306,30 @@ function lazyg { } # Quick Access to System Information -function sysinfo { Get-ComputerInfo } +function sysinfo { + <# + .SYNOPSIS + Detailed system info. + #> + Get-ComputerInfo +} # Networking Utilities function flushdns { + <# + .SYNOPSIS + Clear DNS cache. + #> Clear-DnsClientCache - Write-Host "DNS has been flushed" + Write-PspHost "DNS has been flushed" } # Network Diagnostics function ports { + <# + .SYNOPSIS + Listening TCP ports. + #> try { Get-NetTCPConnection -State Listen -ErrorAction Stop | Sort-Object LocalPort | @@ -2331,27 +3345,37 @@ function ports { } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } - netstat -ano | Select-String 'LISTENING' + # A listening TCP socket has no remote end (0.0.0.0:0 or [::]:0); matching that avoids the localized state column. + netstat -ano -p TCP | Select-String -Pattern '\s(0\.0\.0\.0:0|\[::\]:0)\s' + netstat -ano -p TCPv6 | Select-String -Pattern '\s(0\.0\.0\.0:0|\[::\]:0)\s' } } # Check if a specific port is open on a host (defaults to localhost) function checkport { + <# + .SYNOPSIS + Test TCP connectivity. + #> param( [Parameter(Mandatory)][string]$Hostname, [Parameter(Mandatory)][int]$Port ) $result = Test-NetConnection -ComputerName $Hostname -Port $Port -WarningAction SilentlyContinue if ($result -and $result.TcpTestSucceeded) { - Write-Host "$Hostname`:$Port is OPEN" -ForegroundColor Green + Write-PspHost "$Hostname`:$Port is OPEN" -ForegroundColor Green } else { - Write-Host "$Hostname`:$Port is CLOSED/FILTERED" -ForegroundColor Red + Write-PspHost "$Hostname`:$Port is CLOSED/FILTERED" -ForegroundColor Red } } # List local IPv4 addresses (excluding loopback and APIPA) function localip { + <# + .SYNOPSIS + Local IPv4 addresses. + #> Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -ne '127.0.0.1' -and $_.PrefixOrigin -ne 'WellKnown' } | Select-Object InterfaceAlias, IPAddress, PrefixLength | @@ -2360,6 +3384,10 @@ function localip { # DNS Lookup (defaults to A record, specify type with -Type) function nslook { + <# + .SYNOPSIS + DNS lookup. + #> param( [Parameter(Mandatory)][string]$Domain, [ValidateSet('A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'SRV', 'PTR', 'ANY')][string]$Type = 'A' @@ -2369,6 +3397,10 @@ function nslook { # Security & Crypto function hash { + <# + .SYNOPSIS + File hash (default SHA256). + #> param( [Parameter(Mandatory)][string]$File, [ValidateSet('MD5', 'SHA1', 'SHA256', 'SHA384', 'SHA512')][string]$Algorithm = 'SHA256' @@ -2379,6 +3411,10 @@ function hash { # Verify file integrity by comparing computed hash to expected value (auto-detects algorithm by length) function checksum { + <# + .SYNOPSIS + Verify file hash. + #> param( [Parameter(Mandatory)][string]$File, [Parameter(Mandatory)][string]$Expected @@ -2394,17 +3430,21 @@ function checksum { } $actual = hash -File $File -Algorithm $algo if ($actual -eq $Expected.ToUpper()) { - Write-Host "MATCH ($algo)" -ForegroundColor Green + Write-PspHost "MATCH ($algo)" -ForegroundColor Green } else { - Write-Host "MISMATCH ($algo)" -ForegroundColor Red - Write-Host "Expected: $Expected" - Write-Host "Actual: $actual" + Write-PspHost "MISMATCH ($algo)" -ForegroundColor Red + Write-PspHost "Expected: $Expected" + Write-PspHost "Actual: $actual" } } # Generate a random password of specified length (default 20) and copy to clipboard function genpass { + <# + .SYNOPSIS + Random password (clipboard). + #> param([ValidateRange(1, 1024)][int]$Length = 20) $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}|;:,.<>?' $charCount = $chars.Length @@ -2432,11 +3472,11 @@ function genpass { # Use the host as a fallback when the clipboard is unavailable without writing the password to the success stream. try { Set-Clipboard $password -ErrorAction Stop - Write-Host "Password copied to clipboard." -ForegroundColor Green + Write-PspHost "Password copied to clipboard." -ForegroundColor Green } catch { Write-Warning "Clipboard unavailable ($($_.Exception.Message)); showing the password once below:" - Write-Host $password -ForegroundColor Yellow + Write-PspHost $password -ForegroundColor Yellow } # Keep the plaintext out of the success stream and terminal scrollback. return @@ -2444,12 +3484,20 @@ function genpass { # Base64 encode/decode function b64 { + <# + .SYNOPSIS + Base64 encode. + #> param([Parameter(Mandatory)][string]$Text) [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Text)) } # Base64 decode with error handling function b64d { + <# + .SYNOPSIS + Base64 decode. + #> param([Parameter(Mandatory)][string]$Text) try { [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Text)) } catch { Write-Error "Invalid Base64 input: $_" } @@ -2457,6 +3505,13 @@ function b64d { # VirusTotal file scanner (PS5-compatible, no dependencies) function vtscan { + <# + .SYNOPSIS + VirusTotal hash lookup (-Upload submits). + .EXAMPLE + vtscan .\setup.exe + Look up the file's hash on VirusTotal. + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory)][string]$FilePath, @@ -2464,8 +3519,8 @@ function vtscan { ) $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 - Write-Host 'Or run: vt init' -ForegroundColor Yellow + Write-PspHost 'Set $env:VTCLI_APIKEY first (free key at https://www.virustotal.com/gui/my-apikey)' -ForegroundColor Red + Write-PspHost 'Or run: vt init' -ForegroundColor Yellow return } $resolved = Resolve-Path -LiteralPath $FilePath -ErrorAction SilentlyContinue @@ -2479,8 +3534,8 @@ function vtscan { $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 - Write-Host "SHA256: $sha" -ForegroundColor Cyan + Write-PspHost "`nFile: $($file.Name) ($sizeLabel)" -ForegroundColor Cyan + Write-PspHost "SHA256: $sha" -ForegroundColor Cyan # Lookup by hash first $found = $false @@ -2508,9 +3563,9 @@ function vtscan { $total = [int]$stats.malicious + [int]$stats.undetected + [int]$stats.harmless + [int]$stats.suspicious + [int]$stats.timeout if ($total -eq 0) { $total = 1 } $color = if ($mal -eq 0) { 'Green' } elseif ($mal -le 5) { 'Yellow' } else { 'Red' } - Write-Host "Detections: $mal/$total" -ForegroundColor $color + Write-PspHost "Detections: $mal/$total" -ForegroundColor $color $vtLink = "https://www.virustotal.com/gui/file/$sha/detection" - Write-Host "Link: $vtLink" -ForegroundColor Cyan + Write-PspHost "Link: $vtLink" -ForegroundColor Cyan Start-Process $vtLink $results = $report.data.attributes.last_analysis_results $detections = if ($results) { @@ -2519,10 +3574,10 @@ function vtscan { Sort-Object { $_.Value.engine_name } } if ($detections) { - Write-Host '' + Write-PspHost '' foreach ($d in $detections) { $engine = $d.Value.engine_name.PadRight(20) - Write-Host " $engine $($d.Value.result)" -ForegroundColor Red + Write-PspHost " $engine $($d.Value.result)" -ForegroundColor Red } } return @@ -2530,20 +3585,20 @@ 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 + Write-PspHost 'Hash not found. Not uploading by default.' -ForegroundColor Yellow + Write-PspHost 'Re-run with: vtscan -Upload (submits the file to VirusTotal)' -ForegroundColor Yellow return } - if (-not $PSCmdlet.ShouldProcess($resolved.Path, 'Upload file to VirusTotal')) { + if (-not $PSCmdlet.ShouldProcess($resolved.Path, 'Upload file to VirusTotal (submitted files are shared with the VirusTotal community and its partners)')) { return } - Write-Host 'Hash not found, uploading...' -ForegroundColor Yellow + Write-PspHost 'Hash not found, uploading...' -ForegroundColor Yellow $uploadUrl = 'https://www.virustotal.com/api/v3/files' if ($file.Length -gt 10MB) { try { $uploadUrl = (Invoke-RestMethod -Uri 'https://www.virustotal.com/api/v3/files/upload_url' -Headers $headers -ErrorAction Stop -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 + Write-PspHost 'Using large-file upload endpoint.' -ForegroundColor DarkGray } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -2570,9 +3625,9 @@ function vtscan { return } $link = $parsed.data.links.self - Write-Host "Uploaded. Analysis: $link" -ForegroundColor Green + Write-PspHost "Uploaded. Analysis: $link" -ForegroundColor Green $vtLink = "https://www.virustotal.com/gui/file/$sha/detection" - Write-Host "Link: $vtLink" -ForegroundColor Cyan + Write-PspHost "Link: $vtLink" -ForegroundColor Cyan Start-Process $vtLink } catch { @@ -2581,29 +3636,59 @@ function vtscan { } } -if (-not (Get-Command vt.exe -ErrorAction Ignore)) { +if (-not (Get-Command vt.exe -CommandType Application -ErrorAction Ignore)) { # Fallback when vt-cli not installed: show install instructions function vt { - Write-Host 'vt-cli is not installed. Install with:' -ForegroundColor Red - Write-Host ' winget install VirusTotal.vt-cli' -ForegroundColor Yellow - Write-Host 'Then run: vt init' -ForegroundColor Yellow + <# + .SYNOPSIS + VirusTotal CLI (vt-cli). + #> + Write-PspHost 'vt-cli is not installed. Install with:' -ForegroundColor Red + Write-PspHost ' winget install VirusTotal.vt-cli' -ForegroundColor Yellow + Write-PspHost 'Then run: vt init' -ForegroundColor Yellow } } # Docker Shortcuts (conditional) -if (Get-Command docker -ErrorAction Ignore) { +if (Get-Command docker -CommandType Application -ErrorAction Ignore) { # dps/dpa/dimg: list containers and images - function dps { docker ps @args } - function dpa { docker ps -a @args } - function dimg { docker images @args } + function dps { + <# + .SYNOPSIS + List running containers. + #> + docker ps @args + } + function dpa { + <# + .SYNOPSIS + List all containers. + #> + docker ps -a @args + } + function dimg { + <# + .SYNOPSIS + List images. + #> + docker images @args + } # dlogs: follow container logs; dex: exec into container function dlogs { + <# + .SYNOPSIS + Follow container logs. + #> param([Parameter(Mandatory)][string]$Container) $old = Push-TabTitle "logs: $Container" try { docker logs -f $Container } finally { Pop-TabTitle $old } } function dex { + <# + .SYNOPSIS + Exec into container. + #> param( [Parameter(Mandatory)][string]$Container, [string]$Shell = 'bash' @@ -2612,18 +3697,37 @@ if (Get-Command docker -ErrorAction Ignore) { try { docker exec -it $Container $Shell } finally { Pop-TabTitle $old } } - # dstop: stop all running containers; dprune: system prune + # dstop: stop all running containers; dprune: system prune. Both confirm first (-Confirm:$false skips). function dstop { - $running = docker ps -q - if ($running) { docker stop $running } else { Write-Host "No running containers." } + <# + .SYNOPSIS + Stop all running containers. + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param() + $running = @(docker ps -q) + if (-not $running) { Write-PspHost "No running containers."; return } + if ($PSCmdlet.ShouldProcess("$($running.Count) running container(s)", 'docker stop')) { docker stop $running } + } + function dprune { + <# + .SYNOPSIS + Docker system prune. + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param() + if ($PSCmdlet.ShouldProcess('stopped containers, unused networks, dangling images and build cache', 'docker system prune')) { docker system prune -f } } - function dprune { docker system prune -f } } # Define the WSL wrapper and helpers only when wsl.exe is available. -if (Get-Command wsl.exe -ErrorAction Ignore) { +if (Get-Command wsl.exe -CommandType Application -ErrorAction Ignore) { # Show the WSL distro in the tab title and invoke wsl.exe without recursion. function wsl { + <# + .SYNOPSIS + Wraps wsl.exe; shows distro in tab title. + #> $distro = 'default' for ($i = 0; $i -lt $args.Count; $i++) { $a = [string]$args[$i] @@ -2640,20 +3744,26 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { finally { Pop-TabTitle $oldTitle } } - # Parse installed WSL distros, states, and versions from UTF-16 output. + # List installed WSL distros. Columns are parsed by position and the running set comes from + # 'wsl --list --running --quiet', so localized state words on non-English Windows do not matter. function Get-WslDistro { + <# + .SYNOPSIS + List installed distros with state + version. + #> [CmdletBinding()] param() - $raw = & wsl.exe -l -v 2>&1 + $running = @(& wsl.exe --list --running --quiet 2>$null | ForEach-Object { ([string]$_ -replace "`0", '').Trim() } | Where-Object { $_ }) $results = @() - foreach ($line in $raw) { + foreach ($line in @(& wsl.exe -l -v 2>&1)) { $clean = ([string]$line) -replace "`0", '' - if ($clean -match '^\s*(\*?)\s*([A-Za-z0-9._-]+)\s+(Running|Stopped|Installing|Uninstalling|Converting)\s+(\d+)') { + if ($clean -match '^\s*(\*?)\s*([A-Za-z0-9._-]+)\s+\S.*?\s+(\d+)\s*$') { + $name = $matches[2] $results += [PSCustomObject]@{ Default = ($matches[1] -eq '*') - Name = $matches[2] - State = $matches[3] - Version = [int]$matches[4] + Name = $name + State = $(if ($running -contains $name) { 'Running' } else { 'Stopped' }) + Version = [int]$matches[3] } } } @@ -2662,6 +3772,10 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # Open a WSL shell in the current Windows directory. function Enter-WslHere { + <# + .SYNOPSIS + Open WSL in current Windows directory (alias: wsl-here). + #> [CmdletBinding()] param([string]$Distro) $wslArgs = @('--cd', (Get-Location).ProviderPath) @@ -2672,6 +3786,10 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # Normalize separators and terminate options before converting a Windows path with wslpath. function ConvertTo-WslPath { + <# + .SYNOPSIS + Windows -> WSL path (wslpath -a). + #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Path, @@ -2688,6 +3806,10 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # Normalize separators and terminate options before converting a WSL path with wslpath. function ConvertTo-WindowsPath { + <# + .SYNOPSIS + WSL -> Windows path (wslpath -w). + #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Path, @@ -2704,24 +3826,32 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # Shut down all WSL distros or terminate one named distro. function Stop-Wsl { + <# + .SYNOPSIS + Shutdown all distros or terminate one. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param([string]$Distro) if ($Distro) { if ($PSCmdlet.ShouldProcess($Distro, 'wsl --terminate')) { & wsl.exe --terminate $Distro - Write-Host "Terminated: $Distro" -ForegroundColor Green + Write-PspHost "Terminated: $Distro" -ForegroundColor Green } } else { if ($PSCmdlet.ShouldProcess('all WSL distros', 'wsl --shutdown')) { & wsl.exe --shutdown - Write-Host "All WSL distros stopped." -ForegroundColor Green + Write-PspHost "All WSL distros stopped." -ForegroundColor Green } } } # Return the first IPv4 address reported by a WSL distro. function Get-WslIp { + <# + .SYNOPSIS + Get IPv4 of a running distro. + #> [CmdletBinding()] param([string]$Distro) $wslArgs = @() @@ -2734,6 +3864,10 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # Return FileInfo objects from a running distro through its \\wsl$ UNC path. function Get-WslFile { + <# + .SYNOPSIS + List files inside a distro via UNC; pipe-friendly. + #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Distro, @@ -2766,6 +3900,10 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # Render a bounded WSL directory tree with eza or Get-ChildItem. function Show-WslTree { + <# + .SYNOPSIS + Tree view of a distro path (alias: wsl-tree). + #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Distro, @@ -2775,7 +3913,7 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { ) $unc = Resolve-WslUncPath -Distro $Distro -Path $Path if (-not $unc) { return } - if (Get-Command eza -ErrorAction Ignore) { + if (Get-Command eza -CommandType Application -ErrorAction Ignore) { $ezaArgs = @('--tree', "--level=$Depth", '--icons=auto', '--git-ignore') if ($All) { $ezaArgs += '-a' } & eza @ezaArgs $unc @@ -2790,6 +3928,10 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # Open a WSL distro path in Windows Explorer. function Open-WslExplorer { + <# + .SYNOPSIS + Open distro in Windows Explorer (alias: wsl-explorer). + #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Distro, @@ -2804,6 +3946,10 @@ if (Get-Command wsl.exe -ErrorAction Ignore) { # System Admin function svc { + <# + .SYNOPSIS + Htop-like process viewer. + #> param( [string]$Name, [int]$Count = 25, @@ -2821,9 +3967,9 @@ function svc { $procCount = @(Get-Process).Count $up = (Get-Date) - $bootTime $upStr = '{0}d {1}h {2}m' -f $up.Days, $up.Hours, $up.Minutes - Write-Host '' - Write-Host (' CPU: {0}% | Mem: {1}/{2} GB ({3}%) | Procs: {4} | Up: {5}' -f $cpuLoad, $usedMem, $totalMem, $memPct, $procCount, $upStr) -ForegroundColor Cyan - Write-Host (' ' + ('-' * 70)) -ForegroundColor DarkGray + Write-PspHost '' + Write-PspHost (' CPU: {0}% | Mem: {1}/{2} GB ({3}%) | Procs: {4} | Up: {5}' -f $cpuLoad, $usedMem, $totalMem, $memPct, $procCount, $upStr) -ForegroundColor Cyan + Write-PspHost (' ' + ('-' * 70)) -ForegroundColor DarkGray $procs = if ($Name) { Get-Process -Name "*$Name*" -ErrorAction SilentlyContinue } @@ -2841,18 +3987,51 @@ function svc { } # Reload profile in current session (useful after editing profile_user.ps1 or user-settings.json) -function reload { . $PROFILE } +function reload { + <# + .SYNOPSIS + Reload the profile in-place. + #> + . $PROFILE +} -# Diagnose tools, caches, fonts, PATH, modules, and plugins with OK, WARN, or FAIL results. +# Diagnose version, tools, caches, fonts, PATH, modules, plugins and trust with OK, WARN, or FAIL results. function Test-ProfileHealth { + <# + .SYNOPSIS + Diagnose install: tools, caches, fonts, PATH, modules. + .EXAMPLE + Test-ProfileHealth + Print the health report. + .EXAMPLE + Test-ProfileHealth -PassThru | Where-Object Status -ne 'OK' + Return the rows that need attention. + #> [CmdletBinding()] param( # Emit the result objects for scripting; the rendered report prints either way. [switch]$PassThru ) + $cacheDir = Get-PspCacheDir $results = @() + # Version and install source + $results += [pscustomobject]@{ Category = 'Profile'; Check = 'version'; Status = 'OK'; Detail = "v$($script:PSP.Version)" } + $state = Get-PspInstallState -CacheDir $cacheDir + if ($state) { + $source = @( + if (Get-PspProp $state 'source') { [string]$state.source } + if (Get-PspProp $state 'ref') { "ref $($state.ref)" } + if (Get-PspProp $state 'commit') { "commit $(([string]$state.commit).Substring(0, [Math]::Min(12, ([string]$state.commit).Length)))" } + if (Get-PspProp $state 'updatedAt') { "at $($state.updatedAt)" } + ) -join ', ' + $results += [pscustomobject]@{ Category = 'Profile'; Check = 'installed from'; Status = 'OK'; Detail = $source } + } + else { + $results += [pscustomobject]@{ Category = 'Profile'; Check = 'installed from'; Status = 'WARN'; Detail = 'no install record (installed by an older version or setprofile.ps1)' } + } + # Managed tools foreach ($tool in $script:ProfileTools) { $path = Get-ProfileToolExecutablePath -Tool $tool @@ -2860,25 +4039,25 @@ function Test-ProfileHealth { $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)' } + $results += [pscustomobject]@{ Category = 'Tools'; Check = $tool.Name; Status = 'FAIL'; Detail = 'not installed (Update-Profile -InstallMissingTools or setup.ps1)' } } } # Disk caches (OMP has no init-file cache; it renders via the binary each prompt) foreach ($c in @('zoxide-init.ps1', 'theme.json', 'terminal-config.json')) { $p = Join-Path $cacheDir $c - if (-not (Test-Path $p)) { + if (-not (Test-Path -LiteralPath $p)) { $results += [pscustomobject]@{ Category = 'Caches'; Check = $c; Status = 'WARN'; Detail = 'missing (will regenerate on next load)' } continue } - $size = (Get-Item $p).Length + $size = (Get-Item -LiteralPath $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 + $null = Read-JsonFile $p $results += [pscustomobject]@{ Category = 'Caches'; Check = $c; Status = 'OK'; Detail = "$size bytes, parses" } } catch { @@ -2892,9 +4071,10 @@ function Test-ProfileHealth { # User-settings.json (overrides) $us = Join-Path $cacheDir 'user-settings.json' - if (Test-Path $us) { + $usCfg = $null + if (Test-Path -LiteralPath $us) { try { - $null = Get-Content $us -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $usCfg = Read-JsonFile $us $results += [pscustomobject]@{ Category = 'Config'; Check = 'user-settings.json'; Status = 'OK'; Detail = 'parses' } } catch { @@ -2906,18 +4086,13 @@ function Test-ProfileHealth { } # Windows Terminal settings.json ownership: a symlink or windowsTerminal.manage = false means another tool owns it. - $wtManagedHere = $true - if (Test-Path $us) { - try { - $usCfg = Get-Content $us -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - if ($usCfg.windowsTerminal -and $usCfg.windowsTerminal.PSObject.Properties['manage'] -and $usCfg.windowsTerminal.manage -eq $false) { $wtManagedHere = $false } - } - catch { $null = $_ } - } + $wtManagedHere = -not ($usCfg -and $usCfg.windowsTerminal -and (Test-PspProp $usCfg.windowsTerminal 'manage') -and $usCfg.windowsTerminal.manage -eq $false) + $manifest = Read-WtChangeManifest -ManifestPath (Join-Path $cacheDir 'wt-changes.json') foreach ($wtPath in @(Get-WindowsTerminalSettingsPaths)) { $wtItem = Get-Item -LiteralPath $wtPath -Force -ErrorAction SilentlyContinue if (-not $wtItem) { continue } $wtVariant = if ($wtPath -match 'WindowsTerminalPreview') { 'Preview' } elseif ($wtPath -match 'WindowsTerminalCanary') { 'Canary' } elseif ($wtPath -match 'Packages') { 'Stable' } else { 'Unpackaged' } + $tracked = if (Get-PspProp $manifest.files $wtPath.ToLowerInvariant()) { 'changes recorded for uninstall' } else { 'no recorded changes' } if ($wtItem.LinkType) { $wtTarget = [string](@($wtItem.Target)[0]) if ($wtManagedHere) { @@ -2928,7 +4103,7 @@ function Test-ProfileHealth { } } elseif ($wtManagedHere) { - $results += [pscustomobject]@{ Category = 'Terminal'; Check = "settings.json ($wtVariant)"; Status = 'OK'; Detail = 'managed by this profile' } + $results += [pscustomobject]@{ Category = 'Terminal'; Check = "settings.json ($wtVariant)"; Status = 'OK'; Detail = "managed by this profile, $tracked" } } else { $results += [pscustomobject]@{ Category = 'Terminal'; Check = "settings.json ($wtVariant)"; Status = 'OK'; Detail = 'not managed (windowsTerminal.manage = false)' } @@ -2938,24 +4113,15 @@ function Test-ProfileHealth { # Check the font this install actually uses: a wizard choice in user-settings.json # overrides the shipped terminal-config.json default. $fontName = $null - try { - if (Test-Path $us) { - $userCfg = Get-Content $us -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - if ($userCfg.defaults -and $userCfg.defaults.font -and $userCfg.defaults.font.face) { - $fontName = [string]$userCfg.defaults.font.face - } - } + if ($usCfg -and $usCfg.defaults -and $usCfg.defaults.font -and $usCfg.defaults.font.face) { + $fontName = [string]$usCfg.defaults.font.face } - catch { $null = $_ } if (-not $fontName) { - $tcPath = Join-Path $cacheDir 'terminal-config.json' - if (Test-Path $tcPath) { - try { - $tc = Get-Content $tcPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - if ($tc.fontInstall) { $fontName = [string]$tc.fontInstall.displayName } - } - catch { $null = $_ } + try { + $tc = Read-JsonFile (Join-Path $cacheDir 'terminal-config.json') + if ($tc -and $tc.fontInstall) { $fontName = [string]$tc.fontInstall.displayName } } + catch { $null = $_ } } if ($fontName) { # Report unavailable System.Drawing as WARN on non-Windows platforms. @@ -2991,7 +4157,7 @@ function Test-ProfileHealth { $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)' } + $results += [pscustomobject]@{ Category = 'Fonts'; Check = $fontName; Status = 'FAIL'; Detail = 'not installed (run setup.ps1 -Wizard or setup.ps1)' } } } } @@ -3022,33 +4188,41 @@ function Test-ProfileHealth { # Plugins $pluginDir = Join-Path $cacheDir 'plugins' - if (Test-Path $pluginDir) { - $plugins = @(Get-ChildItem $pluginDir -Filter *.ps1 -ErrorAction SilentlyContinue) + if (Test-Path -LiteralPath $pluginDir) { + $plugins = @(Get-ChildItem -LiteralPath $pluginDir -Filter *.ps1 -ErrorAction SilentlyContinue) $results += [pscustomobject]@{ Category = 'Plugins'; Check = 'user plugins'; Status = 'OK'; Detail = "$($plugins.Count) file(s) in $pluginDir" } } - # Extensibility state + # Extensibility state: a trusted directory whose .psprc.ps1 no longer matches its approval will not run. $trustedCount = if ($script:PSP.TrustedDirs) { $script:PSP.TrustedDirs.Count } else { 0 } - $results += [pscustomobject]@{ Category = 'Extension'; Check = 'trusted directories'; Status = 'OK'; Detail = "$trustedCount entries" } + $stale = @($script:PSP.TrustedDirs | Where-Object { + (Test-Path -LiteralPath (Join-Path $_ '.psprc.ps1')) -and ([string]$script:PSP.TrustedRcHashes[$_]) -ne (Get-PspRcHash -Directory $_) + }) + if ($stale.Count -gt 0) { + $results += [pscustomobject]@{ Category = 'Extension'; Check = 'trusted directories'; Status = 'WARN'; Detail = "$trustedCount entries; .psprc.ps1 needs re-approval in: $($stale -join ', ')" } + } + else { + $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 '====================' + Write-PspHost '' + Write-PspHost 'Profile Health Check' -ForegroundColor Cyan + Write-PspHost '====================' $fmt = '{0,-10} {1,-30} {2,-5} {3}' - Write-Host ($fmt -f 'Category', 'Check', 'Stat', 'Detail') -ForegroundColor DarkGray + Write-PspHost ($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-PspHost ($fmt -f $r.Category, $r.Check, $r.Status, $detail) -ForegroundColor $color } - Write-Host '' + Write-PspHost '' $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 + Write-PspHost ("Summary: {0} OK, {1} WARN, {2} FAIL" -f $okCount, $warnCount, $failCount) -ForegroundColor $summaryColor # Emit objects only on request: a bare call would otherwise render the table a second # time through the default formatter. Scripts use -PassThru to query the rows. @@ -3056,45 +4230,63 @@ function Test-ProfileHealth { } Set-Alias -Name psp-doctor -Value Test-ProfileHealth -Scope Script -# Clear profile and Oh My Posh caches before a terminal restart. +# Clear regenerable profile and Oh My Posh caches before a terminal restart; settings, plugins and install state are kept. function Clear-ProfileCache { - $cacheDir = Join-Path $env:LOCALAPPDATA "PowerShellProfile" - if (-not (Test-Path $cacheDir)) { - Clear-OhMyPoshCaches -Quiet - Write-Host "No cache directory found." -ForegroundColor Yellow + <# + .SYNOPSIS + Reset caches except user settings. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + $cacheDir = Get-PspCacheDir + if (-not (Test-Path -LiteralPath $cacheDir)) { + if ($PSCmdlet.ShouldProcess('Oh My Posh caches', 'Clear')) { Clear-OhMyPoshCaches -Quiet } + Write-PspHost "No cache directory found." -ForegroundColor Yellow return } - # Preserve user settings and plugins while removing regenerable caches. - $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 + # State files are not caches: uninstall needs the telemetry marker and the Windows Terminal change record. + $preservedNames = @('user-settings.json', 'user-settings.json.bak', 'plugins', 'telemetry.owned', 'wt-changes.json', 'installed.json', 'applied-commit.sha', 'logs') + # bg-*.png are resized background images that Windows Terminal points at. + $items = @(Get-ChildItem -LiteralPath $cacheDir -Force -ErrorAction SilentlyContinue | Where-Object { $preservedNames -notcontains $_.Name -and $_.Name -notlike 'bg-*.png' }) + if ($items.Count -eq 0) { + if ($PSCmdlet.ShouldProcess('Oh My Posh caches', 'Clear')) { Clear-OhMyPoshCaches -Quiet } + Write-PspHost "Cache is already clean." -ForegroundColor Green return } foreach ($item in $items) { - if ($item.PSIsContainer) { - Remove-Item $item.FullName -Recurse -Force -ErrorAction SilentlyContinue - } - else { - Remove-Item $item.FullName -Force -ErrorAction SilentlyContinue + if ($PSCmdlet.ShouldProcess($item.FullName, 'Remove cache item')) { + Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-PspHost " Removed $($item.Name)" -ForegroundColor DarkGray } - Write-Host " Removed $($item.Name)" -ForegroundColor DarkGray } - Clear-OhMyPoshCaches -Quiet + if ($PSCmdlet.ShouldProcess('Oh My Posh caches', 'Clear')) { Clear-OhMyPoshCaches -Quiet } Restart-TerminalToApply -Message "Profile cache cleared. Restarting terminal..." } -# Download and run the elevated setup wizard with hash pinning unless explicitly bypassed. +# Download setup.ps1 from a branch, tag or commit and run its wizard in this session, pinned by hash unless -SkipHashCheck. function Invoke-ProfileWizard { + <# + .SYNOPSIS + Re-run install wizard (alias: Reconfigure-Profile). + .EXAMPLE + Invoke-ProfileWizard -Ref v1.0.0 + Run the wizard from setup.ps1 of release v1.0.0, checked against the release's SHA256SUMS.txt. + .EXAMPLE + Invoke-ProfileWizard -Ref -ExpectedSha256 + Run the wizard from a commit's setup.ps1 pinned to the hash an earlier run printed. + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [switch]$Resume, + # Accepted for compatibility: setup.ps1 no longer needs elevation, so the wizard always runs in this session. [switch]$NoElevate, [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedSha256, [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$BundleExpectedSha256, + # Branch, tag (vX.Y.Z) or commit; setup.ps1 and its install bundle both come from the same resolved commit. + [ValidatePattern('^(?!.*\.\.)(?!.*//)[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$')] + [string]$Ref = 'main', [switch]$SkipHashCheck ) @@ -3102,38 +4294,53 @@ function Invoke-ProfileWizard { Write-Error 'Use either -BundleExpectedSha256 or -SkipHashCheck, not both.' return } + if (-not $PSCmdlet.ShouldProcess("setup.ps1 from $($script:PSP.RepoOwner)/$($script:PSP.RepoName)@$Ref", 'Download and run the setup wizard')) { return } - $setupUrl = "$repo_root/$repo_name/main/setup.ps1" - $setupLocal = Join-Path ([System.IO.Path]::GetTempPath()) ("psp-reconfigure-{0}.ps1" -f ([System.IO.Path]::GetRandomFileName())) - - Write-Host "Downloading latest setup.ps1 from $setupUrl" -ForegroundColor Cyan - if (-not $PSCmdlet.ShouldProcess($setupUrl, 'Download and execute remote setup.ps1')) { return } - try { Invoke-DownloadWithRetry -Uri $setupUrl -OutFile $setupLocal } - catch { - Write-Warning "Download failed: $($_.Exception.Message)" - return - } + $commit = Resolve-PspCommitSha -Owner $script:PSP.RepoOwner -Repo $script:PSP.RepoName -Ref $Ref + $sourceRef = if ($commit) { $commit } else { $Ref } + $setupUrl = Get-PspRawUrl -Ref $sourceRef -Path 'setup.ps1' + # A private folder: setup.ps1 treats a folder that also holds the profile files as a local clone. + $workDir = Join-Path ([System.IO.Path]::GetTempPath()) ('psp-reconfigure-' + [System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + $setupLocal = Join-Path $workDir 'setup.ps1' try { + Write-PspHost "Downloading setup.ps1 from $setupUrl" -ForegroundColor Cyan + try { Invoke-DownloadWithRetry -Uri $setupUrl -OutFile $setupLocal } + catch { + Write-Warning "Download failed: $($_.Exception.Message)" + return + } + $actualHash = (Get-FileHash -LiteralPath $setupLocal -Algorithm SHA256).Hash - if ($ExpectedSha256) { + $releaseSums = $null + if (-not $ExpectedSha256 -and -not $SkipHashCheck) { $releaseSums = Get-PspReleaseChecksums -Owner $script:PSP.RepoOwner -Repo $script:PSP.RepoName -Tag $Ref } + if ($releaseSums) { + if ($releaseSums['setup.ps1'] -ne $actualHash) { + Write-Error "setup.ps1 does not match the SHA256SUMS.txt of release $Ref. Aborting." + return + } + Write-PspHost " setup.ps1 matches the SHA256SUMS.txt of release $Ref." -ForegroundColor Green + } + elseif ($ExpectedSha256) { if ($actualHash -ine $ExpectedSha256.Trim()) { Write-Error "SHA256 mismatch. Expected: $ExpectedSha256. Actual: $actualHash. Aborting." return } - Write-Host " Hash verified: $actualHash" -ForegroundColor Green + Write-PspHost " Hash verified: $actualHash" -ForegroundColor Green } elseif (-not $SkipHashCheck) { - Write-Host " Downloaded setup.ps1 SHA256: $actualHash" -ForegroundColor Yellow - Write-Host " (Hash is computed over the download just made; it confirms integrity," -ForegroundColor DarkYellow - Write-Host " not upstream authenticity. Verify the commit out-of-band before pinning.)" -ForegroundColor DarkYellow - Write-Host " Pin it: Invoke-ProfileWizard -ExpectedSha256 '$actualHash'" -ForegroundColor Yellow - Write-Host " Then pin the setup bundle with -BundleExpectedSha256 when prompted." -ForegroundColor Yellow - Write-Host " Or skip: Invoke-ProfileWizard -SkipHashCheck" -ForegroundColor Yellow + Write-PspHost " Downloaded setup.ps1 SHA256: $actualHash (commit $sourceRef)" -ForegroundColor Yellow + Write-PspHost " Release tags (-Ref vX.Y.Z) are checked against the release's SHA256SUMS.txt automatically; check a branch or commit out-of-band." -ForegroundColor DarkYellow + Write-PspHost " Pin it: Invoke-ProfileWizard -Ref $sourceRef -ExpectedSha256 '$actualHash'" -ForegroundColor Yellow + Write-PspHost " Then pin the setup bundle with -BundleExpectedSha256 when prompted." -ForegroundColor Yellow + Write-PspHost " Or skip: Invoke-ProfileWizard -SkipHashCheck" -ForegroundColor Yellow throw "Hash input required. Re-run with -ExpectedSha256 or -SkipHashCheck." } - $shellArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $setupLocal, '-Wizard') + # A release tag is passed on as is, so setup.ps1 checks the bundle against that release's SHA256SUMS.txt. + $setupRef = if ($Ref -match '^v\d+\.\d+\.\d+$') { $Ref } else { $sourceRef } + $shellArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $setupLocal, '-Wizard', '-Ref', $setupRef) if ($Resume) { $shellArgs += '-Resume' } if ($SkipHashCheck) { $shellArgs += '-SkipHashCheck' @@ -3141,39 +4348,38 @@ function Invoke-ProfileWizard { 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 Ignore)) { 'pwsh' } else { 'powershell' } - $exitCode = 1 - - if ($isAdmin -or $NoElevate) { - & $pwshExe @shellArgs - $exitCode = $LASTEXITCODE - } - else { - Write-Host "Launching elevated wizard in a new window ..." -ForegroundColor Cyan - $proc = Start-Process -FilePath $pwshExe -ArgumentList (ConvertTo-NativeArgumentLine $shellArgs) -Verb RunAs -Wait -PassThru - $exitCode = if ($proc) { $proc.ExitCode } else { 1 } + elseif ($setupRef -notmatch '^v\d+\.\d+\.\d+$') { + Write-PspHost " setup.ps1 will stop before making changes and print the install-bundle hash." -ForegroundColor Yellow + Write-PspHost " Re-run with -BundleExpectedSha256 '' or -SkipHashCheck to apply the wizard." -ForegroundColor Yellow } + & (Get-PspShellPath) @shellArgs + $exitCode = $LASTEXITCODE if ($exitCode -eq 0) { - Write-Host "Wizard complete. Reload your shell (run 'reload') to apply changes." -ForegroundColor Green + Write-PspHost "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 + Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue } } Set-Alias -Name Reconfigure-Profile -Value Invoke-ProfileWizard -Scope Script -# Uninstall profile components with granular options. +# Uninstall profile components with granular options; Windows Terminal changes are reverted change by change. function Uninstall-Profile { + <# + .SYNOPSIS + Remove profile, caches, WT changes. + .EXAMPLE + Uninstall-Profile -WhatIf + List everything a plain uninstall would remove or revert. + .EXAMPLE + Uninstall-Profile -All + Also remove tools, fonts and user data. + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [switch]$RemoveTools, @@ -3186,96 +4392,175 @@ function Uninstall-Profile { if ($All) { $RemoveTools = $true; $RemoveUserData = $true; $RemoveFonts = $true } $preserved = @() + $cacheDir = Get-PspCacheDir - # Record telemetry ownership up front: Phase 2 deletes the cache directory this marker lives - # in, so a live run would reach Phase 6 with the evidence already gone and leave the variable - # behind. Under -WhatIf nothing is deleted, which is why the plan alone never showed this. - $telemetryMarker = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\telemetry.owned' + # Read state first: the cache clean-up below removes the files it lives in. + $telemetryMarker = Join-Path $cacheDir 'telemetry.owned' $telemetryOwned = Test-Path -LiteralPath $telemetryMarker - - # Phase 1: Windows Terminal settings - $wtSettingsPath = Get-WindowsTerminalSettingsPath - if ($wtSettingsPath -and (Test-Path (Split-Path $wtSettingsPath))) { - $wtLocalState = Split-Path $wtSettingsPath - $backups = Get-ChildItem -Path $wtLocalState -Filter 'settings.json.*.bak' -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending - - $wtItem = Get-Item -LiteralPath $wtSettingsPath -Force -ErrorAction SilentlyContinue - if ($wtItem -and $wtItem.LinkType) { - Write-Host " $wtSettingsPath is a symlink managed elsewhere; Windows Terminal settings left in place." -ForegroundColor DarkGray - } - elseif ($HardResetWindowsTerminal) { - if (Test-Path $wtSettingsPath) { + $telemetryScope = 'Machine' + if ($telemetryOwned -and (Get-Utf8FileText $telemetryMarker) -match '(?im)^scope:\s*User\b') { $telemetryScope = 'User' } + $manifestPath = Join-Path $cacheDir 'wt-changes.json' + $userSettings = $null + $userSettingsUnreadable = $false + try { $userSettings = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') } catch { $userSettingsUnreadable = $true } + $terminalConfigAtStart = $null + try { $terminalConfigAtStart = Read-JsonFile (Join-Path $cacheDir 'terminal-config.json') } catch { $null = $_ } + $userWt = Get-PspProp $userSettings 'windowsTerminal' + $wtManaged = -not $userSettingsUnreadable -and -not ($userWt -and (Test-PspProp $userWt 'manage') -and $userWt.manage -eq $false) + + # Phase 1: Windows Terminal settings, in every installed variant. + if ($userSettingsUnreadable) { + Write-Warning ' user-settings.json could not be read, so it is unknown whether another tool owns Windows Terminal settings; they are left alone. Fix the file and run Uninstall-Profile again.' + $preserved += 'Windows Terminal changes (user-settings.json could not be read)' + } + elseif (-not $wtManaged) { + Write-PspHost ' Windows Terminal settings left alone (windowsTerminal.manage = false in user-settings.json).' -ForegroundColor DarkGray + } + else { + foreach ($wtSettingsPath in @(Get-WindowsTerminalSettingsPaths)) { + $wtItem = Get-Item -LiteralPath $wtSettingsPath -Force -ErrorAction SilentlyContinue + if ($wtItem -and $wtItem.LinkType) { + Write-PspHost " $wtSettingsPath is a symlink managed elsewhere; Windows Terminal settings left in place." -ForegroundColor DarkGray + continue + } + $profileBackups = Get-WtProfileBackups -Path $wtSettingsPath + if ($HardResetWindowsTerminal) { if ($PSCmdlet.ShouldProcess($wtSettingsPath, 'Delete WT settings for hard reset')) { - Remove-Item $wtSettingsPath -Force -ErrorAction SilentlyContinue - Write-Host ' Deleted Windows Terminal settings.json (WT will recreate defaults on next launch).' -ForegroundColor Green + Remove-Item -LiteralPath $wtSettingsPath -Force -ErrorAction SilentlyContinue + Write-PspHost " Deleted $wtSettingsPath (Windows Terminal recreates defaults on next launch)." -ForegroundColor Green + # The recorded changes belonged to the deleted file. + $hardManifest = Read-WtChangeManifest -ManifestPath $manifestPath + if (Get-PspProp $hardManifest.files $wtSettingsPath.ToLowerInvariant()) { + Remove-PspProp $hardManifest.files $wtSettingsPath.ToLowerInvariant() + Save-WtChangeManifest -ManifestPath $manifestPath -Manifest $hardManifest + } + if ($RemoveUserData) { + foreach ($bak in $profileBackups) { + if ($PSCmdlet.ShouldProcess($bak.FullName, 'Remove WT backup made by this profile')) { + Remove-Item -LiteralPath $bak.FullName -Force -ErrorAction SilentlyContinue + } + } + } + elseif ($profileBackups.Count -gt 0) { + $preserved += "Windows Terminal backups next to $wtSettingsPath, including the pre-install copy (use -RemoveUserData to remove)" + } + } + continue + } + $restored = $false + $hasRecord = [bool](Get-PspProp (Read-WtChangeManifest -ManifestPath $manifestPath).files $wtSettingsPath.ToLowerInvariant()) + if ($hasRecord) { + try { + $undo = Undo-WindowsTerminalSettingsFile -Path $wtSettingsPath -ManifestPath $manifestPath + if ($undo) { + $restored = $true + $keptNote = if ($undo.Kept -gt 0) { "; kept $($undo.Kept) setting(s) changed since the profile wrote them" } else { '' } + Write-PspHost " Reverted $($undo.Reverted) Windows Terminal change(s) in $wtSettingsPath$keptNote." -ForegroundColor Green + } } + catch { Write-Warning " Could not revert recorded changes in ${wtSettingsPath}: $($_.Exception.Message)" } } - if ($backups) { - foreach ($bak in $backups) { - if ($PSCmdlet.ShouldProcess($bak.FullName, 'Remove WT backup')) { - Remove-Item $bak.FullName -Force -ErrorAction SilentlyContinue + else { + # Installed by a version without the change record: restore the oldest copy and keep the current file. + $source = @($profileBackups | Where-Object { $_.Name -like '*.psp-original' }) + @($profileBackups | Where-Object { $_.Name -like '*.bak' } | Sort-Object LastWriteTime) | Select-Object -First 1 + if ($source -and $PSCmdlet.ShouldProcess($wtSettingsPath, "Restore Windows Terminal settings from $($source.Name)")) { + $safety = '{0}.pre-uninstall-{1}.json' -f $wtSettingsPath, (Get-Date -Format 'yyyyMMdd-HHmmss') + try { + Copy-Item -LiteralPath $wtSettingsPath -Destination $safety -Force -ErrorAction Stop + Copy-Item -LiteralPath $source.FullName -Destination $wtSettingsPath -Force -ErrorAction Stop + $restored = $true + Write-PspHost " Restored $wtSettingsPath from $($source.Name)." -ForegroundColor Green + $preserved += "Windows Terminal settings as they were just before uninstall: $safety" } + catch { Write-Warning " Could not restore ${wtSettingsPath}: $($_.Exception.Message)" } } } - } - elseif ($backups) { - $newest = $backups[0] - if ($PSCmdlet.ShouldProcess($wtSettingsPath, "Restore WT settings from $($newest.Name)")) { - Copy-Item -Path $newest.FullName -Destination $wtSettingsPath -Force - Write-Host " Restored WT settings from $($newest.Name)" -ForegroundColor Green - # Only delete backups after a successful restore so they are not lost on a declined prompt - foreach ($bak in $backups) { - if ($PSCmdlet.ShouldProcess($bak.FullName, 'Remove WT backup')) { - Remove-Item $bak.FullName -Force -ErrorAction SilentlyContinue + # Backups go only after a successful restore, so a failed or declined restore loses nothing; the pre-install copy + # stays unless user data is removed too. + if ($restored) { + foreach ($bak in $profileBackups) { + if ($bak.Name -like '*.psp-original' -and -not $RemoveUserData) { + $preserved += "Windows Terminal settings from before the first install: $($bak.FullName) (-RemoveUserData removes it)" + continue + } + if ($PSCmdlet.ShouldProcess($bak.FullName, 'Remove WT backup made by this profile')) { + Remove-Item -LiteralPath $bak.FullName -Force -ErrorAction SilentlyContinue } } } + elseif ($profileBackups.Count -gt 0) { + $preserved += "Windows Terminal backups next to $wtSettingsPath (not restored)" + } + } + } + if (@((Read-WtChangeManifest -ManifestPath $manifestPath).files.PSObject.Properties).Count -gt 0) { + $preserved += "Windows Terminal change record for settings that were not reverted: $manifestPath" + } + + # Phase 2: Telemetry opt-out, removed only when setup set it (ownership marker) and at the scope it used. + $telemetrySet = [bool][System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', $telemetryScope) + if ($telemetryOwned -and $telemetrySet) { + if ($telemetryScope -eq 'Machine' -and -not $script:PSP.IsAdmin) { + Write-Warning ' POWERSHELL_TELEMETRY_OPTOUT was set machine-wide by setup; re-run Uninstall-Profile in an elevated shell to remove it.' + $preserved += 'POWERSHELL_TELEMETRY_OPTOUT (machine-wide; re-run Uninstall-Profile elevated to remove it)' + } + elseif ($PSCmdlet.ShouldProcess("POWERSHELL_TELEMETRY_OPTOUT ($telemetryScope)", 'Remove environment variable')) { + [System.Environment]::SetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', $null, $telemetryScope) + Remove-Item -LiteralPath $telemetryMarker -Force -ErrorAction SilentlyContinue + Write-PspHost " Removed POWERSHELL_TELEMETRY_OPTOUT ($telemetryScope)." -ForegroundColor Green } } + elseif ($telemetryOwned) { + # Variable already gone; drop the stale marker so repeat uninstalls stay idempotent. + if ($PSCmdlet.ShouldProcess($telemetryMarker, 'Remove stale telemetry marker')) { Remove-Item -LiteralPath $telemetryMarker -Force -ErrorAction SilentlyContinue } + } + elseif ([System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine') -or [System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'User')) { + Write-PspHost ' Leaving POWERSHELL_TELEMETRY_OPTOUT alone (not set by this profile).' -ForegroundColor DarkGray + } - # Phase 2: Cache cleanup - $cacheDir = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' - if (Test-Path $cacheDir) { + # Phase 3: Cache cleanup. User data stays unless requested; state that is still needed stays too. + if (Test-Path -LiteralPath $cacheDir) { $excludes = @() - # Preserve user settings and plugins unless explicitly requested. - if (-not $RemoveUserData) { $excludes += 'user-settings.json'; $excludes += 'plugins' } - $cacheItems = Get-ChildItem $cacheDir -ErrorAction SilentlyContinue | - Where-Object { $excludes -notcontains $_.Name } + if (-not $RemoveUserData) { $excludes += 'user-settings.json', 'user-settings.json.bak', 'plugins' } + if (Test-Path -LiteralPath $telemetryMarker) { $excludes += 'telemetry.owned' } + if (Test-Path -LiteralPath $manifestPath) { $excludes += 'wt-changes.json' } + $cacheItems = Get-ChildItem -LiteralPath $cacheDir -Force -ErrorAction SilentlyContinue | + Where-Object { $excludes -notcontains $_.Name } foreach ($item in $cacheItems) { $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 + Remove-Item -LiteralPath $item.FullName -Force -Recurse -ErrorAction SilentlyContinue + Write-PspHost " Removed $($item.Name)" -ForegroundColor DarkGray } } if (-not $RemoveUserData) { $preserved += 'user-settings.json (use -RemoveUserData to remove)' - if (Test-Path (Join-Path $cacheDir 'plugins')) { $preserved += 'plugins/ (use -RemoveUserData to remove)' } + if (Test-Path -LiteralPath (Join-Path $cacheDir 'plugins')) { $preserved += 'plugins/ (use -RemoveUserData to remove)' } } # Remove empty cache dir - $remaining = Get-ChildItem $cacheDir -ErrorAction SilentlyContinue + $remaining = Get-ChildItem -LiteralPath $cacheDir -Force -ErrorAction SilentlyContinue if (-not $remaining) { if ($PSCmdlet.ShouldProcess($cacheDir, 'Remove empty cache directory')) { - Remove-Item $cacheDir -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $cacheDir -Force -ErrorAction SilentlyContinue } } } - # Phase 2b: Temporary profile artifacts + # Phase 3b: Temporary profile artifacts $tempArtifactPatterns = @( 'psp-wizard-state.json' 'psp-profile-*.ps1' 'psp-theme-*.json' 'psp-terminal-*.json' - 'psp-reconfigure-*.ps1' + 'psp-setup-*' + 'psp-reconfigure-*' ) foreach ($pattern in $tempArtifactPatterns) { - $orphans = Get-ChildItem -Path $env:TEMP -Filter $pattern -File -ErrorAction SilentlyContinue + $orphans = Get-ChildItem -LiteralPath $env:TEMP -Filter $pattern -Force -ErrorAction SilentlyContinue foreach ($orphan in $orphans) { - if ($PSCmdlet.ShouldProcess($orphan.FullName, 'Remove leftover temp file')) { - Remove-Item $orphan.FullName -Force -ErrorAction SilentlyContinue - Write-Host " Removed $($orphan.Name)" -ForegroundColor DarkGray + if ($PSCmdlet.ShouldProcess($orphan.FullName, 'Remove leftover temp item')) { + Remove-Item -LiteralPath $orphan.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-PspHost " Removed $($orphan.Name)" -ForegroundColor DarkGray } } } @@ -3283,7 +4568,7 @@ function Uninstall-Profile { # Gated so -WhatIf stays read-only; the OMP cache clear deletes files of its own. if ($PSCmdlet.ShouldProcess('Oh My Posh caches', 'Clear')) { Clear-OhMyPoshCaches -Quiet } - # Phase 3: PSFzf module - opt-in with -RemoveTools, since it is one of the managed tools. + # Phase 4: PSFzf module - opt-in with -RemoveTools, since it is one of the managed tools. $isCiOrAgent = (($env:CI -or $env:AI_AGENT) -and -not $Force) $psfzfPresent = [bool](Get-Module -ListAvailable -Name PSFzf) if ($psfzfPresent -and -not $RemoveTools) { @@ -3302,7 +4587,7 @@ function Uninstall-Profile { catch { $null = $_ } try { Uninstall-Module -Name PSFzf -AllVersions -Force -ErrorAction Stop - Write-Host ' Uninstalled PSFzf module.' -ForegroundColor Green + Write-PspHost ' Uninstalled PSFzf module.' -ForegroundColor Green $uninstalled = $true } catch { @@ -3311,38 +4596,23 @@ function Uninstall-Profile { if (-not $uninstalled) { # Retry uninstall from a background shell to release the module. - $psExe = $null - $cmd = Get-Command pwsh -ErrorAction Ignore - if ($cmd) { - $psExe = $cmd.Source - } - else { - $cmd = Get-Command powershell -ErrorAction Ignore - if ($cmd) { $psExe = $cmd.Source } - } - - if ($psExe) { - try { - Start-Process -FilePath $psExe -ArgumentList @( - '-NoProfile' - '-NonInteractive' - '-Command' - "try { Uninstall-Module -Name PSFzf -AllVersions -Force -ErrorAction SilentlyContinue } catch { `$null = `$_ }" - ) -WindowStyle Hidden | Out-Null - Write-Host ' Scheduled PSFzf uninstall in background session.' -ForegroundColor Yellow - } - catch { - Write-Warning " Failed to schedule PSFzf uninstall in background session: $_" - } + try { + Start-Process -FilePath (Get-PspShellPath) -ArgumentList @( + '-NoProfile' + '-NonInteractive' + '-Command' + "try { Uninstall-Module -Name PSFzf -AllVersions -Force -ErrorAction SilentlyContinue } catch { `$null = `$_ }" + ) -WindowStyle Hidden | Out-Null + Write-PspHost ' Scheduled PSFzf uninstall in background session.' -ForegroundColor Yellow } - else { - Write-Warning ' Could not locate pwsh or powershell to retry PSFzf uninstall in a background session.' + catch { + Write-Warning " Failed to schedule PSFzf uninstall in background session: $_" } } } } - # Phase 4: Managed tools (winget by default, direct/MSI aware for Oh My Posh) + # Phase 5: Managed tools (winget by default, direct/MSI aware for Oh My Posh) if ($RemoveTools) { if ($isCiOrAgent) { Write-Warning ' Skipping managed tool uninstall under CI/agent environment ($env:CI/$env:AI_AGENT set). Re-run with -Force to uninstall them anyway.' @@ -3376,7 +4646,7 @@ function Uninstall-Profile { $toolLocation = if (-not $wingetPath -and $tool.Cmd -ne 'oh-my-posh') { 'install present but winget is unavailable' } elseif ($tool.Cmd -eq 'oh-my-posh') { 'direct/MSI install without winget registration' } else { 'local install without winget registration' } - Write-Host " Preserving $($tool.Name) ($toolLocation)." -ForegroundColor DarkGray + Write-PspHost " Preserving $($tool.Name) ($toolLocation)." -ForegroundColor DarkGray } continue } @@ -3386,7 +4656,7 @@ function Uninstall-Profile { try { $wingetOutput = @(& $wingetPath uninstall -e --id $tool.Id --silent 2>&1) if ($LASTEXITCODE -eq 0) { - Write-Host " Uninstalled $($tool.Name)" -ForegroundColor Green + Write-PspHost " Uninstalled $($tool.Name)" -ForegroundColor Green } else { $wingetMessage = (@($wingetOutput) -join ' ').Trim() @@ -3402,16 +4672,11 @@ function Uninstall-Profile { } if ($removalMode -eq 'msi') { - if (-not $ompMsiProductCode) { - Write-Warning ' Could not locate an MSI product code for Oh My Posh.' - continue - } - if ($PSCmdlet.ShouldProcess($tool.Name, 'Uninstall via MSI')) { try { - $msiProc = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $ompMsiProductCode, '/qn', '/norestart') -Wait -PassThru -WindowStyle Hidden + $msiProc = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') -ArgumentList @('/x', $ompMsiProductCode, '/qn', '/norestart') -Wait -PassThru -WindowStyle Hidden if ($msiProc.ExitCode -in @(0, 1605, 3010)) { - Write-Host " Uninstalled $($tool.Name)" -ForegroundColor Green + Write-PspHost " Uninstalled $($tool.Name)" -ForegroundColor Green } else { Write-Warning " Failed to uninstall $($tool.Name) via MSI (exit $($msiProc.ExitCode))." @@ -3425,32 +4690,23 @@ function Uninstall-Profile { } } } - elseif (-not $RemoveTools) { $preserved += 'Managed tools (use -RemoveTools to uninstall)' } + else { $preserved += 'Managed tools (use -RemoveTools to uninstall)' } - # Phase 5: Nerd Fonts (opt-in). Shell font installs land per-user on Windows 10/11 unless + # Phase 6: Nerd Fonts (opt-in). Shell font installs land per-user on Windows 10/11 unless # elevated, so both scopes are swept; only the machine-wide one needs admin. if ($RemoveFonts) { # Derive the uninstall font filter from what setup actually installed: a wizard font choice # in user-settings.json wins over terminal-config.json, which wins over the install default. $fontDisplayName = 'CaskaydiaCove NF' try { - $tcPath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\terminal-config.json' - if (Test-Path $tcPath) { - $tc = Get-Content $tcPath -Raw | ConvertFrom-Json - if ($tc.fontInstall.displayName) { $fontDisplayName = $tc.fontInstall.displayName } - } + # Read before the cache cleanup removed it. + $tc = $terminalConfigAtStart + if ($tc -and $tc.fontInstall -and $tc.fontInstall.displayName) { $fontDisplayName = [string]$tc.fontInstall.displayName } } catch { $null = $_ } - try { - $usPath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile\user-settings.json' - if (Test-Path $usPath) { - $usCfg = Get-Content $usPath -Raw | ConvertFrom-Json - if ($usCfg.defaults -and $usCfg.defaults.font -and $usCfg.defaults.font.face) { - $fontDisplayName = [string]$usCfg.defaults.font.face - } - } + if ($userSettings -and $userSettings.defaults -and $userSettings.defaults.font -and $userSettings.defaults.font.face) { + $fontDisplayName = [string]$userSettings.defaults.font.face } - catch { $null = $_ } $tokens = @($fontDisplayName -split '\s+' | Where-Object { $_ }) # Registry entries keep the display form ("CaskaydiaCove NF Bold (TrueType)")... $regNamePattern = ($tokens | ForEach-Object { [regex]::Escape($_) }) -join '.*' @@ -3460,11 +4716,14 @@ function Uninstall-Profile { $fileNamePattern = '^' + [regex]::Escape($tokens[0]) + '.*NerdFont' $fontExtensions = @('.ttf', '.otf') - $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - $fontScopes = @( - @{ Name = 'per-user'; Dir = (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts'); Reg = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts'; NeedsAdmin = $false } - @{ Name = 'machine-wide'; Dir = (Join-Path $env:SystemRoot 'Fonts'); Reg = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts'; NeedsAdmin = $true } - ) + $isElevated = $script:PSP.IsAdmin + # Tests point this at sandbox folders; nothing outside tests sets it. + $fontScopes = if ($script:PSP.ContainsKey('TestFontScopes') -and $script:PSP.TestFontScopes) { @($script:PSP.TestFontScopes) } else { + @( + @{ Name = 'per-user'; Dir = (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts'); Reg = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts'; NeedsAdmin = $false } + @{ Name = 'machine-wide'; Dir = (Join-Path $env:SystemRoot 'Fonts'); Reg = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts'; NeedsAdmin = $true } + ) + } $fontMatches = 0 $removedFontCount = 0 @@ -3475,7 +4734,7 @@ function Uninstall-Profile { foreach ($scope in $fontScopes) { if ($scope.NeedsAdmin -and -not $isElevated) { # Only nag about elevation when that scope actually holds matching fonts. - $blocked = @(Get-ChildItem $scope.Dir -ErrorAction SilentlyContinue | + $blocked = @(Get-ChildItem -LiteralPath $scope.Dir -ErrorAction SilentlyContinue | Where-Object { $fontExtensions -contains $_.Extension -and $_.Name -match $fileNamePattern }) if ($blocked) { $skippedMachineScope = $true } continue @@ -3488,28 +4747,25 @@ function Uninstall-Profile { if ($regEntries) { foreach ($prop in @($regEntries.PSObject.Properties | Where-Object { $_.Name -match $regNamePattern })) { $fontFile = [string]$prop.Value - if ($fontFile) { - # Machine entries often store a bare filename; per-user ones store a full path. - if (-not [System.IO.Path]::IsPathRooted($fontFile)) { $fontFile = Join-Path $scope.Dir $fontFile } - # Only ever touch files inside the store this scope owns. Registry values are - # absolute, so without this an env-redirected run (tests, sandboxes) would - # follow them straight back out to the real font directory. - $inScope = $false - if ($scopeDirFull) { - try { - $inScope = [System.IO.Path]::GetFullPath($fontFile).StartsWith( - $scopeDirFull + [System.IO.Path]::DirectorySeparatorChar, - [System.StringComparison]::OrdinalIgnoreCase) - } - catch { $inScope = $false } + # An entry without a file, or one pointing outside this scope's store, is left alone. + if (-not $fontFile) { continue } + # Machine entries often store a bare filename; per-user ones store a full path. + if (-not [System.IO.Path]::IsPathRooted($fontFile)) { $fontFile = Join-Path $scope.Dir $fontFile } + $inScope = $false + if ($scopeDirFull) { + try { + $inScope = [System.IO.Path]::GetFullPath($fontFile).StartsWith( + $scopeDirFull + [System.IO.Path]::DirectorySeparatorChar, + [System.StringComparison]::OrdinalIgnoreCase) } - if (-not $inScope) { continue } - if ((Test-Path -LiteralPath $fontFile) -and $handledFontFiles.Add($fontFile)) { - $fontMatches++ - if ($PSCmdlet.ShouldProcess($fontFile, 'Remove font file')) { - Remove-Item -LiteralPath $fontFile -Force -ErrorAction SilentlyContinue - $removedFontCount++ - } + catch { $inScope = $false } + } + if (-not $inScope) { continue } + if ((Test-Path -LiteralPath $fontFile) -and $handledFontFiles.Add($fontFile)) { + $fontMatches++ + if ($PSCmdlet.ShouldProcess($fontFile, 'Remove font file')) { + Remove-Item -LiteralPath $fontFile -Force -ErrorAction SilentlyContinue + $removedFontCount++ } } if ($PSCmdlet.ShouldProcess($prop.Name, "Remove $($scope.Name) font registry entry")) { @@ -3519,7 +4775,7 @@ function Uninstall-Profile { } # Then sweep files whose registry entry was already gone. - foreach ($orphan in @(Get-ChildItem $scope.Dir -ErrorAction SilentlyContinue | + foreach ($orphan in @(Get-ChildItem -LiteralPath $scope.Dir -ErrorAction SilentlyContinue | Where-Object { $fontExtensions -contains $_.Extension -and $_.Name -match $fileNamePattern })) { if (-not $handledFontFiles.Add($orphan.FullName)) { continue } $fontMatches++ @@ -3531,10 +4787,10 @@ function Uninstall-Profile { } if ($removedFontCount -gt 0) { - Write-Host " Removed $removedFontCount $fontDisplayName font file(s)." -ForegroundColor Green + Write-PspHost " Removed $removedFontCount $fontDisplayName font file(s)." -ForegroundColor Green } elseif ($fontMatches -eq 0) { - Write-Host " No Nerd Font files matching '$fontDisplayName' found to remove." -ForegroundColor DarkGray + Write-PspHost " No Nerd Font files matching '$fontDisplayName' found to remove." -ForegroundColor DarkGray } if ($skippedMachineScope) { Write-Warning ' Machine-wide font files need an elevated (admin) terminal. Re-run elevated to remove them.' @@ -3543,28 +4799,6 @@ function Uninstall-Profile { } else { $preserved += 'Nerd Fonts (use -RemoveFonts to remove)' } - # Phase 6: Remove the telemetry opt-out only when the ownership marker exists. - $isElevatedNow = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - if ([System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine')) { - if (-not $telemetryOwned) { - 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 - } - } - else { - 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) $profileDirs = @( @@ -3574,53 +4808,98 @@ function Uninstall-Profile { foreach ($dir in $profileDirs) { $mainProfile = Join-Path $dir 'Microsoft.PowerShell_profile.ps1' $originalBackup = Join-Path $dir 'profile.original.ps1' - if (Test-Path $originalBackup) { + $originalRestored = $false + $hasOriginal = Test-Path -LiteralPath $originalBackup + if ($hasOriginal -and (Test-PspProfileFile -Path $originalBackup)) { + # Setup versions before 1.0.0 could save an installed copy of this profile as the original; it is not the user's. + $hasOriginal = $false + if ($PSCmdlet.ShouldProcess($originalBackup, 'Remove profile.original.ps1 (a copy of this profile, not the pre-install one)')) { + Remove-Item -LiteralPath $originalBackup -Force -ErrorAction SilentlyContinue + } + } + if ($hasOriginal) { # Restore the pre-install profile saved on first install instead of leaving none behind. if ($PSCmdlet.ShouldProcess($mainProfile, 'Restore original profile saved before first install')) { - Copy-Item -Path $originalBackup -Destination $mainProfile -Force -ErrorAction SilentlyContinue - Remove-Item $originalBackup -Force -ErrorAction SilentlyContinue - Write-Host " Restored original profile to $mainProfile" -ForegroundColor Green + try { + Copy-Item -LiteralPath $originalBackup -Destination $mainProfile -Force -ErrorAction Stop + Remove-Item -LiteralPath $originalBackup -Force -ErrorAction SilentlyContinue + $originalRestored = $true + Write-PspHost " Restored original profile to $mainProfile" -ForegroundColor Green + } + catch { + Write-Warning " Could not restore $mainProfile from profile.original.ps1: $($_.Exception.Message)" + $preserved += "profile.original.ps1 in $dir (restore failed; copy it over Microsoft.PowerShell_profile.ps1 yourself)" + } } } - elseif (Test-Path $mainProfile) { + elseif (Test-Path -LiteralPath $mainProfile) { if ($PSCmdlet.ShouldProcess($mainProfile, 'Remove profile file')) { - Remove-Item $mainProfile -Force -ErrorAction SilentlyContinue - Write-Host " Removed $mainProfile" -ForegroundColor DarkGray + Remove-Item -LiteralPath $mainProfile -Force -ErrorAction SilentlyContinue + Write-PspHost " Removed $mainProfile" -ForegroundColor DarkGray + } + } + # Older profile copies made on each install or update. Without profile.original.ps1 the oldest may be + # the profile from before the first install, so they are only removed when that is not the case. + $oldCopies = @(Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue) + if ($oldCopies.Count -gt 0) { + if ($originalRestored -or $RemoveUserData) { + foreach ($copy in $oldCopies) { + if ($PSCmdlet.ShouldProcess($copy.FullName, 'Remove older profile copy')) { Remove-Item -LiteralPath $copy.FullName -Force -ErrorAction SilentlyContinue } + } + } + else { + $preserved += "$($oldCopies.Count) older profile copy(ies) in $dir (oldprofile*.ps1; the oldest may be your pre-install profile; -RemoveUserData removes them)" } } if ($RemoveUserData) { - $userProf = Join-Path $dir 'profile_user.ps1' - if (Test-Path $userProf) { - if ($PSCmdlet.ShouldProcess($userProf, 'Remove user profile overrides')) { - Remove-Item $userProf -Force -ErrorAction SilentlyContinue - Write-Host " Removed $userProf" -ForegroundColor DarkGray + foreach ($userFile in @('profile_user.ps1', 'profile_user.ps1.bak')) { + $userProf = Join-Path $dir $userFile + if (Test-Path -LiteralPath $userProf) { + if ($PSCmdlet.ShouldProcess($userProf, 'Remove user profile overrides')) { + Remove-Item -LiteralPath $userProf -Force -ErrorAction SilentlyContinue + Write-PspHost " Removed $userProf" -ForegroundColor DarkGray + } } } } - elseif (Test-Path (Join-Path $dir 'profile_user.ps1')) { + elseif (Test-Path -LiteralPath (Join-Path $dir 'profile_user.ps1')) { $preserved += "profile_user.ps1 in $dir (use -RemoveUserData to remove)" } + # Leave no empty profile directory behind. + if ((Test-Path -LiteralPath $dir) -and -not (Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue)) { + if ($PSCmdlet.ShouldProcess($dir, 'Remove empty profile directory')) { Remove-Item -LiteralPath $dir -Force -ErrorAction SilentlyContinue } + } } # Phase 8: Summary - Write-Host '' + Write-PspHost '' if ($preserved) { - Write-Host '' - Write-Host 'Preserved:' -ForegroundColor Yellow - foreach ($p in $preserved) { Write-Host " - $p" -ForegroundColor DarkGray } - Write-Host '' + Write-PspHost '' + Write-PspHost 'Preserved:' -ForegroundColor Yellow + foreach ($p in $preserved) { Write-PspHost " - $p" -ForegroundColor DarkGray } + Write-PspHost '' if (-not $All) { - Write-Host 'Use Uninstall-Profile -All to remove everything.' -ForegroundColor Yellow + Write-PspHost 'Use Uninstall-Profile -All to remove everything.' -ForegroundColor Yellow } } Restart-TerminalToApply -Message "Uninstall complete. Restarting terminal..." } # Utility -function path { $env:PATH -split ';' | Where-Object { $_ } } +function path { + <# + .SYNOPSIS + Display PATH entries. + #> + $env:PATH -split ';' | Where-Object { $_ } +} # Fetch weather by city or IP geolocation through wttr.in with an Open-Meteo fallback. function weather { + <# + .SYNOPSIS + Weather lookup. + #> param([string]$City) $encoded = if ($City) { [System.Uri]::EscapeDataString($City) } else { '' } # Try wttr.in first, fall back to Open-Meteo if unreachable @@ -3656,7 +4935,7 @@ function weather { 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' } - Write-Host "$($loc.name): ${temp}${unit}" -ForegroundColor Cyan + Write-PspHost "$($loc.name): ${temp}${unit}" -ForegroundColor Cyan } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -3664,47 +4943,60 @@ function weather { } } -# Retrieve Windows WiFi profiles and passwords through netsh. +# List saved WiFi profiles from netsh's XML export, which is the same in every Windows language; passwords stay masked unless -Reveal. function wifipass { + <# + .SYNOPSIS + List saved WiFi profiles (passwords masked; -Reveal to show). + .EXAMPLE + wifipass + List saved WiFi profiles with masked passwords. + .EXAMPLE + wifipass HomeWifi -Reveal + Show one stored password (run elevated). + #> param([string]$SSID, [switch]$Reveal) - # masked by default so passwords don't land in scrollback/screen-shares; -Reveal to print. - $mask = { param($p) if (-not $p) { '(no password stored)' } elseif ($Reveal) { $p } else { '******** (use -Reveal to show)' } } + $exportDir = Join-Path ([System.IO.Path]::GetTempPath()) ('psp-wlan-' + [System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $exportDir -Force | Out-Null try { - if ($SSID) { - $safeName = $SSID -replace '["\r\n`&|<>^;$(){}]', '' - $output = netsh wlan show profile name="$safeName" key=clear 2>&1 - if ($LASTEXITCODE -ne 0) { Write-Error "Profile '$safeName' not found."; return } - $line = $output | Select-String 'Key Content' - $parts = if ($line) { ($line -split ':', 2) } else { $null } - $pass = if ($parts -and $parts.Count -gt 1) { $parts[1].Trim() } else { $null } - Write-Host "$safeName : $(& $mask $pass)" -ForegroundColor Green + # key=clear writes plaintext keys into the export, so it is requested only with -Reveal and the folder is always deleted. + $exportArgs = @('wlan', 'export', 'profile', "folder=$exportDir") + if ($SSID) { $exportArgs += 'name=' + ($SSID -replace '["\r\n`&|<>^;$(){}]', '') } + if ($Reveal) { $exportArgs += 'key=clear' } + $null = & netsh.exe @exportArgs 2>&1 + $files = @(Get-ChildItem -LiteralPath $exportDir -Filter *.xml -File -ErrorAction SilentlyContinue) + if ($files.Count -eq 0) { + if ($SSID) { Write-Error "Profile '$SSID' not found." } else { Write-PspHost 'No saved WiFi profiles (or WLAN AutoConfig is not running).' -ForegroundColor Yellow } + return } - else { - $profiles = netsh wlan show profiles 2>&1 | Select-String 'All User Profile' | ForEach-Object { - $p = ($_ -split ':', 2) - if ($p.Count -gt 1) { $p[1].Trim() } - } | Where-Object { $_ } - foreach ($p in $profiles) { - $p = $p -replace '["\r\n`&|<>^;$(){}]', '' - $detail = netsh wlan show profile name="$p" key=clear 2>&1 - $key = $detail | Select-String 'Key Content' - $keyParts = if ($key) { ($key -split ':', 2) } else { $null } - $pass = if ($keyParts -and $keyParts.Count -gt 1) { $keyParts[1].Trim() } else { $null } - Write-Host "${p} : $(& $mask $pass)" - } + foreach ($f in $files) { + $doc = New-Object System.Xml.XmlDocument + $doc.Load($f.FullName) + $name = [string]$doc.WLANProfile.name + $shared = $doc.WLANProfile.MSM.security.sharedKey + $display = if (-not $shared) { '(no password stored)' } + elseif ($Reveal -and [string]$shared.protected -eq 'false') { [string]$shared.keyMaterial } + elseif ($Reveal) { '(stored encrypted; run elevated to reveal)' } + else { '******** (use -Reveal to show)' } + Write-PspHost "$name : $display" } } catch { Write-Error "Failed to query WiFi profiles: $_" } + finally { Remove-Item -LiteralPath $exportDir -Recurse -Force -ErrorAction SilentlyContinue } } # Open the hosts file with an elevated preferred editor. function hosts { + <# + .SYNOPSIS + Open hosts file (elevated). + #> $hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts' $editor = Resolve-PreferredEditor $cmdInfo = Get-Command $editor -ErrorAction Ignore $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 (ConvertTo-NativeArgumentLine (@('/c', $editorPath) + @($script:ResolvedEditorArgs) + @($hostsPath))) + Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\cmd.exe') -Verb RunAs -WindowStyle Hidden -ArgumentList (ConvertTo-NativeArgumentLine (@('/c', $editorPath) + @($script:ResolvedEditorArgs) + @($hostsPath))) } else { Start-Process -FilePath $editorPath -ArgumentList (ConvertTo-NativeArgumentLine (@($script:ResolvedEditorArgs) + @($hostsPath))) -Verb RunAs @@ -3713,7 +5005,11 @@ function hosts { # Estimate download speed in Mbps through Cloudflare. function speedtest { - Write-Host "Testing download speed..." -ForegroundColor Cyan + <# + .SYNOPSIS + Download speed test. + #> + Write-PspHost "Testing download speed..." -ForegroundColor Cyan $url = "https://speed.cloudflare.com/__down?bytes=25000000" $start = Get-Date $oldTitle = Push-TabTitle 'speedtest' @@ -3721,7 +5017,7 @@ function speedtest { 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 + Write-PspHost "Download: ~${mbps} Mbps ($([math]::Round($elapsed, 1))s for 25 MB)" -ForegroundColor Green } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -3732,6 +5028,10 @@ function speedtest { # Return a human-readable file size or recursive directory size. function sizeof { + <# + .SYNOPSIS + Human-readable size. + #> param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { Write-Error "Path not found: $Path"; return } $item = Get-Item -LiteralPath $Path @@ -3750,6 +5050,10 @@ function sizeof { # Show recent System and Application events with timestamp, level, ID, and message. function eventlog { + <# + .SYNOPSIS + Last event log entries. + #> param([ValidateRange(1, 10000)][int]$Count = 20) Get-WinEvent -LogName System, Application -MaxEvents $Count -ErrorAction SilentlyContinue | Sort-Object TimeCreated -Descending | @@ -3758,9 +5062,13 @@ function eventlog { } # SSH & Remote -if (Get-Command ssh -ErrorAction Ignore) { +if (Get-Command ssh -CommandType Application -ErrorAction Ignore) { # Add short SSH timeout and keepalive defaults without overriding user options. function ssh { + <# + .SYNOPSIS + Ssh.exe wrapper with fast-fail timeout + keepalive. + #> # Match both spaced and compact OpenSSH -o option forms. $argText = ($args -join ' ') # Use the first positional SSH argument as the tab title and as the host for config lookup. @@ -3811,25 +5119,38 @@ if (Get-Command ssh -ErrorAction Ignore) { finally { Pop-TabTitle $oldTitle } } - # Copy SSH public key to remote host (ssh-copy-id equivalent) + # Copy SSH public key to remote host (ssh-copy-id equivalent; the remote side needs a POSIX shell) function Copy-SshKey { + <# + .SYNOPSIS + Copy SSH key to remote. + .EXAMPLE + Copy-SshKey user@server + Append your public key to the remote authorized_keys unless it is already there. + #> param([Parameter(Mandatory)][string]$RemoteHost) - $keyPath = if (Test-Path "$env:USERPROFILE\.ssh\id_ed25519.pub") { "$env:USERPROFILE\.ssh\id_ed25519.pub" } - elseif (Test-Path "$env:USERPROFILE\.ssh\id_rsa.pub") { "$env:USERPROFILE\.ssh\id_rsa.pub" } - else { $null } - if (-not $keyPath) { Write-Error "No SSH public key found in $env:USERPROFILE\.ssh\"; return } - $keyContent = Get-Content $keyPath -Raw + # Remote script: skips a key that is already present and never glues a key onto an unterminated last line. + $remoteScript = 'umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && tmp=~/.ssh/.psp-key-$$ && tr -d ''\r'' | sed ''/^$/d'' > $tmp && { [ $(tail -c1 $tmp | wc -l) -gt 0 ] || echo >> $tmp; } && if grep -qxFf $tmp ~/.ssh/authorized_keys; then echo PSP_KEY_ALREADY_PRESENT; else { [ ! -s ~/.ssh/authorized_keys ] || [ $(tail -c1 ~/.ssh/authorized_keys | wc -l) -gt 0 ] || echo >> ~/.ssh/authorized_keys; } && cat $tmp >> ~/.ssh/authorized_keys; fi; rc=$?; rm -f $tmp; chmod 600 ~/.ssh/authorized_keys; chmod 700 ~/.ssh; exit $rc' + $sshDir = Join-Path $env:USERPROFILE '.ssh' + $keyPath = @('id_ed25519.pub', 'id_rsa.pub') | ForEach-Object { Join-Path $sshDir $_ } | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $keyPath) { Write-Error "No SSH public key found in $sshDir\"; return } + $keyContent = Get-Content -LiteralPath $keyPath -Raw if (-not $keyContent) { Write-Error "SSH key file is empty: $keyPath"; return } $key = $keyContent.Trim() -replace "`r", '' - Write-Host "Copying $keyPath to $RemoteHost..." -ForegroundColor Cyan - $key | ssh $RemoteHost "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && chmod 700 ~/.ssh" - if ($LASTEXITCODE -eq 0) { Write-Host "Key copied successfully." -ForegroundColor Green } - else { Write-Error "Failed to copy key." } + Write-PspHost "Copying $keyPath to $RemoteHost..." -ForegroundColor Cyan + $output = @($key | ssh $RemoteHost $remoteScript) + if ($LASTEXITCODE -ne 0) { Write-Error "Failed to copy key."; return } + if ($output -contains 'PSP_KEY_ALREADY_PRESENT') { Write-PspHost "Key already authorized on $RemoteHost; nothing changed." -ForegroundColor DarkGray } + else { Write-PspHost "Key copied successfully." -ForegroundColor Green } } Set-Alias -Name ssh-copy-key -Value Copy-SshKey # Generate a named ED25519 key pair without overwriting an existing key. function keygen { + <# + .SYNOPSIS + Generate ed25519 key pair. + #> param([string]$Name = 'id_ed25519') $keyPath = Join-Path "$env:USERPROFILE\.ssh" $Name ssh-keygen -t ed25519 -f $keyPath @@ -3838,25 +5159,45 @@ if (Get-Command ssh -ErrorAction Ignore) { # Open Remote Desktop Connection for a computer name or IP address. function rdp { + <# + .SYNOPSIS + Launch RDP session. + #> param([Parameter(Mandatory)][string]$Computer) mstsc "/v:$Computer" } # Developer Utilities +# Stop the processes using a TCP port; core Windows processes (svchost, lsass, ...) are never stopped. function killport { + <# + .SYNOPSIS + Kill process on TCP port. + .EXAMPLE + killport 3000 -WhatIf + Show which process listens on port 3000. + .EXAMPLE + killport 3000 + Stop it. + #> + [CmdletBinding(SupportsShouldProcess = $true)] param([Parameter(Mandatory)][int]$Port) $connections = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue - if (-not $connections) { Write-Host "Nothing listening on port $Port." -ForegroundColor Yellow; return } + if (-not $connections) { Write-PspHost "Nothing listening on port $Port." -ForegroundColor Yellow; return } $pids = $connections | Select-Object -ExpandProperty OwningProcess -Unique $killed = 0 foreach ($procId in $pids) { - if ($procId -eq 0 -or $procId -eq 4) { + if ($procId -le 4) { Write-Warning "Port $Port is owned by System (PID $procId) - skipping." continue } $proc = Get-Process -Id $procId -ErrorAction SilentlyContinue - if ($proc) { - Write-Host "Stopping $($proc.ProcessName) (PID $procId) on port $Port" -ForegroundColor Cyan + if ($proc -and $script:ProtectedProcessNames -contains $proc.ProcessName) { + Write-Warning "Port $Port belongs to $($proc.ProcessName) (PID $procId), a core Windows process; stopping it can crash or restart Windows. Skipping." + continue + } + if ($proc -and $PSCmdlet.ShouldProcess("$($proc.ProcessName) (PID $procId) on port $Port", 'Stop process')) { + Write-PspHost "Stopping $($proc.ProcessName) (PID $procId) on port $Port" -ForegroundColor Cyan try { Stop-Process -Id $procId -Force -ErrorAction Stop $killed++ @@ -3864,16 +5205,20 @@ function killport { catch { Write-Warning "Could not stop PID ${procId}: $_" } } } - if ($killed -gt 0) { Write-Host "Port $Port freed." -ForegroundColor Green } + if ($killed -gt 0) { Write-PspHost "Port $Port freed." -ForegroundColor Green } else { Write-Warning "No processes were stopped on port $Port." } } # Select listening processes through fzf or Out-GridView and terminate them. function Stop-ListeningPort { + <# + .SYNOPSIS + Interactive picker for listening ports (alias: killports). + #> [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 } + if (-not $conns) { Write-PspHost "No listening TCP ports." -ForegroundColor Yellow; return } $rows = foreach ($c in $conns) { $proc = Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue [PSCustomObject]@{ @@ -3890,7 +5235,7 @@ function Stop-ListeningPort { ('{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 } + if (-not $picked) { Write-PspHost '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 { $_ } @@ -3901,17 +5246,17 @@ function Stop-ListeningPort { } 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 } + if (-not $selected) { Write-PspHost '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)." + if ($row.PID -le 4 -or $script:ProtectedProcessNames -contains $row.Process) { + Write-Warning "Skipping core Windows process $($row.Process) (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 + Write-PspHost ("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) } } @@ -3923,13 +5268,13 @@ Set-Alias -Name killports -Value Stop-ListeningPort # Compile the Windows Restart Manager wrapper once per session. function Initialize-RestartManagerType { - if ('PSP.RestartManager' -as [type]) { return } + if ('PSP.RestartManagerV2' -as [type]) { return } Add-Type -Language CSharp -TypeDefinition @' using System; using System.Collections.Generic; using System.Runtime.InteropServices; namespace PSP { - public static class RestartManager { + public static class RestartManagerV2 { [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; @@ -3949,13 +5294,13 @@ namespace PSP { [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) { + public static List WhoIsLocking(string[] paths) { 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); + r = RmRegisterResources(handle, (uint)paths.Length, paths, 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); @@ -3963,7 +5308,7 @@ namespace PSP { 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); + for (int i = 0; i < count; i++) { if (!ids.Contains(info[i].Process.dwProcessId)) ids.Add(info[i].Process.dwProcessId); } } else if (r != 0) throw new Exception("RmGetList failed: " + r); } @@ -3975,17 +5320,34 @@ namespace PSP { '@ -ErrorAction Stop } -# Find processes holding a file or directory open. +# Find processes holding a file open, or any file inside a folder (up to -MaxFiles files are checked). function Find-FileLocker { + <# + .SYNOPSIS + Show processes holding a file/folder lock. + .EXAMPLE + Find-FileLocker .\app.log + List the processes that have the file open. + #> [CmdletBinding()] - param([Parameter(Mandatory)][string]$Path) + param([Parameter(Mandatory)][string]$Path, [ValidateRange(1, 100000)][int]$MaxFiles = 1000) $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) + # Restart Manager tracks open files, not folders, so a folder is checked through the files inside it. + $paths = @($resolved.ProviderPath) + if (Test-Path -LiteralPath $resolved.ProviderPath -PathType Container) { + $paths = @(Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -File -Force -ErrorAction SilentlyContinue | Select-Object -First $MaxFiles | ForEach-Object { $_.FullName }) + if ($paths.Count -eq 0) { + Write-PspHost "No files inside $($resolved.ProviderPath) to check (Restart Manager cannot see handles on the folder itself)." -ForegroundColor Yellow + return + } + if ($paths.Count -ge $MaxFiles) { Write-Warning "Checked the first $MaxFiles files only; raise -MaxFiles to check more." } + } + $ids = [PSP.RestartManagerV2]::WhoIsLocking([string[]]$paths) if (-not $ids -or $ids.Count -eq 0) { - Write-Host "No process is holding a lock on: $($resolved.ProviderPath)" -ForegroundColor Green + Write-PspHost "No process is holding a lock on: $($resolved.ProviderPath)" -ForegroundColor Green return } foreach ($procId in $ids) { @@ -4009,6 +5371,10 @@ function Find-FileLocker { # Escalate process termination from Stop-Process to taskkill for names, IDs, or pipeline input. function Stop-StuckProcess { + <# + .SYNOPSIS + Escalating kill: Stop-Process -> taskkill /F -> /F /T. + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByName', ValueFromPipeline)] @@ -4034,7 +5400,7 @@ function Stop-StuckProcess { $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 } + if (-not $p) { Write-PspHost "PID $procId already gone." -ForegroundColor DarkGray; continue } # Never escalate termination for core processes or system PIDs. if ($procId -le 4 -or $script:ProtectedProcessNames -contains $p.ProcessName) { Write-Warning "Refusing to stop protected system process: $($p.ProcessName) (PID $procId)." @@ -4046,7 +5412,7 @@ function Stop-StuckProcess { 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 + Write-PspHost "Stopped: $label" -ForegroundColor Green continue } # Stage 2: taskkill /F (with /T if requested) @@ -4055,7 +5421,7 @@ function Stop-StuckProcess { & 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 + Write-PspHost "Force-killed: $label" -ForegroundColor Yellow } else { Write-Error "Could not stop: $label. Likely needs SYSTEM privileges (protected process, antivirus, driver-held handle)." @@ -4065,52 +5431,74 @@ function Stop-StuckProcess { } # Terminate file lockers before retrying deletion. +# Delete a locked file or folder: list the processes holding it, confirm each one, stop them, then retry the delete. function Remove-LockedItem { + <# + .SYNOPSIS + Find lockers, kill, and delete (combo). + .EXAMPLE + Remove-LockedItem .\build -Recurse + Delete a folder, stopping the processes that lock files in it after confirming each one. + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory, Position = 0)][string]$Path, [switch]$Recurse, + # Also end child processes of each locker (taskkill /T); off by default because it can close unrelated windows. + [switch]$Tree, [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 } + if (-not $PSCmdlet.ShouldProcess($resolved.ProviderPath, 'Delete')) { return } # Try direct deletion before investigating locks. try { Remove-Item -LiteralPath $resolved.ProviderPath -Recurse:$Recurse -Force -ErrorAction Stop - Write-Host "Deleted: $($resolved.ProviderPath)" -ForegroundColor Green + Write-PspHost "Deleted: $($resolved.ProviderPath)" -ForegroundColor Green return } catch { - Write-Host "Initial delete failed, investigating lockers..." -ForegroundColor Yellow + Write-PspHost "Initial delete failed, investigating lockers..." -ForegroundColor Yellow } - $lockers = @(Find-FileLocker -Path $resolved.ProviderPath) + $lockers = @(Find-FileLocker -Path $resolved.ProviderPath | Where-Object { $_ -is [System.Management.Automation.PSCustomObject] }) if (-not $lockers) { Write-Error "Delete failed but no lockers reported. Check permissions or path." -ErrorAction Continue return } - Write-Host "Lockers found:" -ForegroundColor Cyan + Write-PspHost "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')) { + if (-not $KillSystem -and ($l.PID -le 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 + # One confirmation per process, now that the user can see what holds the lock. + if (-not $PSCmdlet.ShouldProcess("$($l.Name) (PID $($l.PID)) holding $($resolved.ProviderPath)", 'Stop process')) { continue } + Stop-StuckProcess -Id $l.PID -Tree:$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 + Write-PspHost "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 + Write-PspHost 'Next steps: reboot, or check for SYSTEM/driver lock via Sysinternals Handle/Process Explorer.' -ForegroundColor DarkGray } } # Make configurable HTTP requests with JSON formatting and binary response handling. function http { + <# + .SYNOPSIS + HTTP requests with auto JSON format. + .EXAMPLE + http https://api.github.com/zen + GET a URL and print the body. + .EXAMPLE + http https://httpbin.org/post -Method POST -Body '{"a":1}' + POST JSON. + #> param( [Parameter(Mandatory)][string]$Url, [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD')] @@ -4132,7 +5520,7 @@ function http { $response = Invoke-WebRequest @params $contentTypeHeader = [string]$response.Headers['Content-Type'] if ($contentTypeHeader -and $contentTypeHeader -match 'octet-stream|image/|audio/|video/|application/zip|application/pdf') { - Write-Host "Binary response ($contentTypeHeader), $($response.RawContentLength) bytes" -ForegroundColor Yellow + Write-PspHost "Binary response ($contentTypeHeader), $($response.RawContentLength) bytes" -ForegroundColor Yellow } elseif ($contentTypeHeader -and $contentTypeHeader -match 'json') { $response.Content | ConvertFrom-Json | ConvertTo-Json -Depth 10 @@ -4145,7 +5533,7 @@ function http { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode - Write-Host "$status $($_.Exception.Response.StatusCode)" -ForegroundColor Red + Write-PspHost "$status $($_.Exception.Response.StatusCode)" -ForegroundColor Red # PS7 uses HttpResponseMessage (no GetResponseStream); PS5 uses HttpWebResponse if ($_.ErrorDetails.Message) { $_.ErrorDetails.Message @@ -4166,6 +5554,10 @@ function http { # Pretty-print JSON from a file or pipeline input. function prettyjson { + <# + .SYNOPSIS + Pretty-print JSON. + #> param( [Parameter(Position = 0)] [string]$File @@ -4177,7 +5569,7 @@ function prettyjson { } elseif ($File) { if (-not (Test-Path -LiteralPath $File)) { Write-Error "File not found: $File"; return } - Get-Content -LiteralPath $File -Raw | ConvertFrom-Json | ConvertTo-Json -Depth 10 + Get-Utf8FileText $File | ConvertFrom-Json | ConvertTo-Json -Depth 10 } else { Write-Error 'Usage: prettyjson or | prettyjson' } } @@ -4186,6 +5578,10 @@ function prettyjson { # JWT decode (strips Bearer prefix, decodes header + payload without verification) function jwtd { + <# + .SYNOPSIS + Decode JWT. + #> param([Parameter(Mandatory)][string]$Token) $Token = $Token -replace '^Bearer\s+', '' $parts = $Token -split '\.' @@ -4198,7 +5594,7 @@ function jwtd { elseif ($mod -eq 3) { $b64 += '=' } try { $json = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64)) - Write-Host "${label}:" -ForegroundColor Cyan + Write-PspHost "${label}:" -ForegroundColor Cyan $json | ConvertFrom-Json | ConvertTo-Json -Depth 10 } catch { Write-Error "Failed to decode ${label}: $_" } @@ -4207,6 +5603,10 @@ function jwtd { # Unix timestamp converter (no args = now, number = epoch to date, date string = date to epoch) function epoch { + <# + .SYNOPSIS + Unix timestamp converter. + #> param([string]$Value) $unixEpoch = [DateTime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) if (-not $Value) { @@ -4229,24 +5629,40 @@ function epoch { # Generate UUID/GUID and copy to clipboard function uuid { + <# + .SYNOPSIS + Generate UUID. + #> $id = [guid]::NewGuid().ToString() Set-Clipboard $id - Write-Host "$id (copied)" -ForegroundColor Green + Write-PspHost "$id (copied)" -ForegroundColor Green } # URL encode / decode function urlencode { + <# + .SYNOPSIS + URL encode. + #> param([Parameter(Mandatory)][string]$Text) [System.Uri]::EscapeDataString($Text) } # Decode URL-encoded string function urldecode { + <# + .SYNOPSIS + URL decode. + #> param([Parameter(Mandatory)][string]$Text) [System.Uri]::UnescapeDataString($Text) } # Measure execution time of a scriptblock function timer { + <# + .SYNOPSIS + Measure execution time. + #> param([Parameter(Mandatory)][scriptblock]$Command) $oldTitle = Push-TabTitle 'timer' $sw = [System.Diagnostics.Stopwatch]::StartNew() @@ -4255,45 +5671,104 @@ function timer { $sw.Stop() Pop-TabTitle $oldTitle } - Write-Host ('Elapsed: {0:N3}s' -f $sw.Elapsed.TotalSeconds) -ForegroundColor Cyan + Write-PspHost ('Elapsed: {0:N3}s' -f $sw.Elapsed.TotalSeconds) -ForegroundColor Cyan +} + +# Return $true when an environment variable looks like a secret, by its name or by a credential-shaped value. +function Test-PspSensitiveVariable { + param([Parameter(Mandatory)][string]$Name, [AllowNull()][string]$Value) + if ($Name -match '(?i)(passw|(^|_)pass($|_|phrase)|_pwd$|secret|token|api[_-]?key|apikey|credential|connection[_-]?string|private[_-]?key|access[_-]?key|account[_-]?key|session[_-]?key|signing[_-]?key|_pat$|^pat$)') { return $true } + if (-not $Value) { return $false } + $shapes = @( + '\bgh[pousr]_[A-Za-z0-9]{20,}', '\bgithub_pat_[A-Za-z0-9_]{20,}', '\b(AKIA|ASIA)[0-9A-Z]{16}\b', '(?i)\bxox[baprs]-[A-Za-z0-9-]{10,}', + '\bAIza[0-9A-Za-z_\-]{35}\b', '\bsk-[A-Za-z0-9_-]{20,}', '(?i)\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}', '-----BEGIN[A-Z ]*PRIVATE KEY-----', + '\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*', '(?i)://[^/\s:@]+:[^/\s:@]+@', + '(?i)\bAccountKey=[A-Za-z0-9+/=]{20,}', '(?i)(^|;)\s*(password|pwd)\s*=\s*[^;]+', '(?i)\bSharedAccessSignature=|[?&]sig=[A-Za-z0-9%+/=]{20,}' + ) + foreach ($shape in $shapes) { if ($Value -match $shape) { return $true } } + return $false } -# Search/list environment variables +# Search/list environment variables; values that look like secrets are masked unless -ShowValues. function env { - param([string]$Pattern) + <# + .SYNOPSIS + Search/list environment variables. + .EXAMPLE + env PATH + Show variables whose name or value matches PATH. + .EXAMPLE + env TOKEN -ShowValues + Show matching variables without masking. + #> + param([string]$Pattern, [switch]$ShowValues) $vars = Get-ChildItem env: | Sort-Object Name if ($Pattern) { $vars = $vars | Where-Object { $_.Name -match $Pattern -or $_.Value -match $Pattern } } - $vars | Format-Table Name, Value -AutoSize -Wrap + $vars | ForEach-Object { + $masked = -not $ShowValues -and (Test-PspSensitiveVariable -Name $_.Name -Value $_.Value) + [PSCustomObject]@{ Name = $_.Name; Value = $(if ($masked) { '******** (env -ShowValues to reveal)' } else { $_.Value }) } + } | Format-Table Name, Value -AutoSize -Wrap } -# Check TLS certificate expiry and details for a domain -function tlscert { - param( - [Parameter(Mandatory)][string]$Domain, - [int]$Port = 443 - ) - $tcp = $null; $ssl = $null; $cert = $null +# Open a TLS session for inspection, capturing the validation errors instead of failing on them. +function Get-PspTlsSession { + param([Parameter(Mandatory)][string]$Domain, [Parameter(Mandatory)][int]$Port) + $tcp = New-Object System.Net.Sockets.TcpClient try { - $tcp = New-Object System.Net.Sockets.TcpClient $async = $tcp.BeginConnect($Domain, $Port, $null, $null) - if (-not $async.AsyncWaitHandle.WaitOne(5000)) { - throw "Connection to ${Domain}:${Port} timed out after 5 seconds" - } + if (-not $async.AsyncWaitHandle.WaitOne(5000)) { throw "Connection to ${Domain}:${Port} timed out after 5 seconds" } $tcp.EndConnect($async) $stream = $tcp.GetStream() $stream.ReadTimeout = 10000 $stream.WriteTimeout = 10000 - $ssl = New-Object System.Net.Security.SslStream($stream, $false, { $true }) + $policy = @{ Errors = [System.Net.Security.SslPolicyErrors]::None } + # Accept every certificate so it can be shown, but remember why Windows would have rejected it. + $callback = [System.Net.Security.RemoteCertificateValidationCallback] { param($s, $c, $ch, $e) $policy.Errors = $e; $true }.GetNewClosure() + $ssl = New-Object System.Net.Security.SslStream($stream, $false, $callback) $ssl.AuthenticateAsClient($Domain) - $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ssl.RemoteCertificate) + [PSCustomObject]@{ Tcp = $tcp; Ssl = $ssl; Errors = $policy.Errors } + } + catch { + $tcp.Dispose() + throw + } +} + +# Describe SslPolicyErrors in plain words ('' when the certificate validates). +function Format-PspTlsErrors { + param([System.Net.Security.SslPolicyErrors]$Errors, [string]$Domain) + $problems = @() + if ($Errors -band [System.Net.Security.SslPolicyErrors]::RemoteCertificateNameMismatch) { $problems += "name does not match $Domain" } + if ($Errors -band [System.Net.Security.SslPolicyErrors]::RemoteCertificateChainErrors) { $problems += 'chain not trusted (expired, self-signed, revoked or unknown CA)' } + if ($Errors -band [System.Net.Security.SslPolicyErrors]::RemoteCertificateNotAvailable) { $problems += 'no certificate presented' } + $problems -join '; ' +} + +# Check TLS certificate expiry, details and validation status for a domain +function tlscert { + <# + .SYNOPSIS + TLS certificate details. + #> + param( + [Parameter(Mandatory)][string]$Domain, + [int]$Port = 443 + ) + $session = $null; $cert = $null + try { + $session = Get-PspTlsSession -Domain $Domain -Port $Port + $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($session.Ssl.RemoteCertificate) $daysLeft = [math]::Floor(($cert.NotAfter - (Get-Date)).TotalDays) $color = if ($daysLeft -lt 30) { 'Red' } elseif ($daysLeft -lt 90) { 'Yellow' } else { 'Green' } - Write-Host " Subject: $($cert.Subject)" -ForegroundColor White - Write-Host " Issuer: $($cert.Issuer)" -ForegroundColor White - Write-Host " Valid from: $($cert.NotBefore)" -ForegroundColor White - Write-Host " Expires: $($cert.NotAfter)" -ForegroundColor White - Write-Host " Days left: $daysLeft" -ForegroundColor $color - Write-Host " Thumbprint: $($cert.Thumbprint)" -ForegroundColor DarkGray + $problems = Format-PspTlsErrors -Errors $session.Errors -Domain $Domain + Write-PspHost " Subject: $($cert.Subject)" -ForegroundColor White + Write-PspHost " Issuer: $($cert.Issuer)" -ForegroundColor White + Write-PspHost " Valid from: $($cert.NotBefore)" -ForegroundColor White + Write-PspHost " Expires: $($cert.NotAfter)" -ForegroundColor White + Write-PspHost " Days left: $daysLeft" -ForegroundColor $color + if ($problems) { Write-PspHost " Validation: FAILED - $problems" -ForegroundColor Red } + else { Write-PspHost " Validation: OK (trusted chain, name matches)" -ForegroundColor Green } + Write-PspHost " Thumbprint: $($cert.Thumbprint)" -ForegroundColor DarkGray } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -4301,19 +5776,24 @@ function tlscert { } finally { if ($cert) { $cert.Dispose() } - if ($ssl) { $ssl.Dispose() } - if ($tcp) { $tcp.Dispose() } + if ($session) { $session.Ssl.Dispose(); $session.Tcp.Dispose() } } } # Quick TCP port scan function portscan { - [CmdletBinding()] + <# + .SYNOPSIS + Quick TCP port scan. + #> + [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][string]$Hostname, [int[]]$Ports = @(21, 22, 25, 53, 80, 443, 445, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 27017) ) - Write-Host "Scanning $Hostname..." -ForegroundColor Cyan + Write-PspHost 'Scan only systems you own or are explicitly authorized to test.' -ForegroundColor DarkYellow + if (-not $PSCmdlet.ShouldProcess($Hostname, "TCP connect scan of $($Ports.Count) port(s)")) { return } + Write-PspHost "Scanning $Hostname..." -ForegroundColor Cyan $open = 0 foreach ($port in $Ports) { $tcp = New-Object System.Net.Sockets.TcpClient @@ -4326,7 +5806,7 @@ function portscan { catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }; $connected = $false } } if ($connected) { - Write-Host (" {0,-6} open" -f $port) -ForegroundColor Green + Write-PspHost (" {0,-6} open" -f $port) -ForegroundColor Green $open++ } } @@ -4336,23 +5816,27 @@ function portscan { } finally { $tcp.Dispose() } } - if ($open -eq 0) { Write-Host " No open ports found." -ForegroundColor Yellow } - Write-Host ("Scan complete ({0}/{1} open)." -f $open, $Ports.Count) -ForegroundColor Cyan + if ($open -eq 0) { Write-PspHost " No open ports found." -ForegroundColor Yellow } + Write-PspHost ("Scan complete ({0}/{1} open)." -f $open, $Ports.Count) -ForegroundColor Cyan } # Look up IP geolocation through the keyless ipwho.is HTTPS API. function ipinfo { + <# + .SYNOPSIS + IP geolocation lookup. + #> param([string]$IpAddress) $url = if ($IpAddress) { "https://ipwho.is/$IpAddress" } else { "https://ipwho.is/" } try { $info = Invoke-RestMethod -Uri $url -TimeoutSec 10 -UseBasicParsing if (-not $info) { Write-Error "IP lookup returned no data."; return } if (-not $info.success) { Write-Error "Lookup failed: $($info.message)"; return } - Write-Host " IP: $($info.ip)" -ForegroundColor White - Write-Host " Location: $($info.city), $($info.region), $($info.country)" -ForegroundColor White - Write-Host " ISP: $($info.connection.isp)" -ForegroundColor White - Write-Host " Org: $($info.connection.org)" -ForegroundColor DarkGray - Write-Host " AS: AS$($info.connection.asn)" -ForegroundColor DarkGray + Write-PspHost " IP: $($info.ip)" -ForegroundColor White + Write-PspHost " Location: $($info.city), $($info.region), $($info.country)" -ForegroundColor White + Write-PspHost " ISP: $($info.connection.isp)" -ForegroundColor White + Write-PspHost " Org: $($info.connection.org)" -ForegroundColor DarkGray + Write-PspHost " AS: AS$($info.connection.asn)" -ForegroundColor DarkGray } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -4362,16 +5846,24 @@ function ipinfo { # Quick timestamped backup of a file function bak { + <# + .SYNOPSIS + Timestamped backup. + #> param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { Write-Error "File not found: $Path"; return } $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $dest = "$Path.$timestamp.bak" Copy-Item -LiteralPath $Path -Destination $dest -Force - Write-Host "Backup: $dest" -ForegroundColor Green + Write-PspHost "Backup: $dest" -ForegroundColor Green } # Repeat a command at intervals (like Linux watch) function watch { + <# + .SYNOPSIS + Repeat command every n seconds. + #> param( [Parameter(Mandatory)][scriptblock]$Command, [int]$Interval = 2 @@ -4380,15 +5872,15 @@ function watch { 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 + Write-PspHost "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 "" + Write-PspHost ("watch: every {0}s | {1}" -f $Interval, (Get-Date -Format "HH:mm:ss")) -ForegroundColor DarkGray + Write-PspHost "" try { & $Command } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } - Write-Host $_.Exception.Message -ForegroundColor Red + Write-PspHost $_.Exception.Message -ForegroundColor Red } try { Start-Sleep -Seconds $Interval } catch { @@ -4401,17 +5893,21 @@ function watch { # WHOIS domain lookup via RDAP (IANA standard, no external tools needed) function whois { + <# + .SYNOPSIS + WHOIS domain lookup. + #> param([Parameter(Mandatory)][string]$Domain) $Domain = $Domain -replace '^https?://', '' -replace '/.*$', '' try { $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 + Write-PspHost " Domain: $($rdap.ldhName)" -ForegroundColor White + Write-PspHost " Status: $(@($rdap.status) -join ', ')" -ForegroundColor White if ($rdap.entities) { $registrar = $rdap.entities | Where-Object { $_.roles -contains 'registrar' } | Select-Object -First 1 if ($registrar -and $registrar.vcardArray -and @($registrar.vcardArray).Count -gt 1) { $fn = $registrar.vcardArray[1] | Where-Object { $_ -and $_[0] -eq 'fn' } | ForEach-Object { $_[3] } - if ($fn) { Write-Host " Registrar: $fn" -ForegroundColor White } + if ($fn) { Write-PspHost " Registrar: $fn" -ForegroundColor White } } } foreach ($ev in @($rdap.events)) { @@ -4425,12 +5921,12 @@ function whois { if ($label) { # Preserve unparseable registrar event dates as raw values. $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 + Write-PspHost " ${label}:$((' ' * [math]::Max(1, 12 - $label.Length)))$date" -ForegroundColor White } } if ($rdap.nameservers) { $ns = ($rdap.nameservers | ForEach-Object { $_.ldhName }) -join ', ' - Write-Host " Nameservers: $ns" -ForegroundColor DarkGray + Write-PspHost " Nameservers: $ns" -ForegroundColor DarkGray } } catch { @@ -4443,21 +5939,37 @@ function whois { } # Clipboard Utilities -function cpy { if (-not $args) { Write-Error "Usage: cpy "; return }; Set-Clipboard ($args -join ' ') } +function cpy { + <# + .SYNOPSIS + Copy text to clipboard. + #> + if (-not $args) { Write-Error "Usage: cpy "; return }; Set-Clipboard ($args -join ' ') +} # Paste from clipboard -function pst { Get-Clipboard } +function pst { + <# + .SYNOPSIS + Paste from clipboard. + #> + Get-Clipboard +} # Safely insert clipboard text into the prompt buffer (never executes directly) function Invoke-Clipboard { + <# + .SYNOPSIS + Safely insert clipboard text into the prompt buffer (never executes directly). + #> $clipboardText = Get-Clipboard -Raw if ([string]::IsNullOrWhiteSpace($clipboardText)) { - Write-Host "Clipboard is empty." -ForegroundColor Yellow + Write-PspHost "Clipboard is empty." -ForegroundColor Yellow return } # Insert clipboard contents into the prompt buffer without execution. try { - if ($isInteractive -and (Get-Module PSReadLine -ErrorAction SilentlyContinue)) { + if ($script:PSP.IsInteractive -and (Get-Module PSReadLine -ErrorAction SilentlyContinue)) { [Microsoft.PowerShell.PSConsoleReadLine]::Insert($clipboardText) return } @@ -4622,6 +6134,13 @@ Register-ArgumentCompleter -CommandName Register-ProfileHook -ParameterName Even # Read Windows events with optional severity filtering and two-second follow polling. function journal { + <# + .SYNOPSIS + Tail Windows Event Log. + .EXAMPLE + journal System -Count 20 -Level Error + Show the last 20 errors from the System log. + #> param( [string]$LogName = 'System', [int]$Count = 50, @@ -4642,13 +6161,13 @@ function journal { } $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 + Write-PspHost ("{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 } + if (-not $events) { Write-PspHost "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 + Write-PspHost "Following $LogName. Ctrl+C to stop." -ForegroundColor DarkGray $lastTime = ($events | Sort-Object TimeCreated | Select-Object -Last 1).TimeCreated $oldTitle = Push-TabTitle "journal: $LogName" try { @@ -4670,11 +6189,15 @@ function journal { # List disks and partitions in a pretty tree (Linux lsblk equivalent). function lsblk { + <# + .SYNOPSIS + List disks and partitions. + #> $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 + Write-PspHost ("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) @@ -4687,25 +6210,33 @@ function lsblk { $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) + Write-PspHost (" {0,-3} {1,-6} {2,7} GB {3,-22} {4}" -f $p.PartitionNumber, $letter, $pSize, $p.Type, $label) } } } # Open an installed terminal process viewer or fall back to svc -Live. function htop { + <# + .SYNOPSIS + Interactive process viewer. + #> foreach ($c in @('btop', 'ntop', 'htop')) { # -CommandType Application so 'htop' resolves to a real binary, not this function (which would self-shadow and skip the svc fallback). $cmd = Get-Command $c -CommandType Application -ErrorAction Ignore 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 + Write-PspHost 'No TUI process viewer installed. Tip: winget install aristocratos.btop4win' -ForegroundColor Yellow + Write-PspHost 'Falling back to svc -Live.' -ForegroundColor DarkGray svc -Live } # Combine traceroute with a configurable number of pings per hop. function mtr { + <# + .SYNOPSIS + Traceroute + per-hop ping. + #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Target, @@ -4714,10 +6245,10 @@ function mtr { ) $oldTitle = Push-TabTitle "mtr: $Target" try { - Write-Host "Tracing route to $Target..." -ForegroundColor Cyan + Write-PspHost "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 + Write-PspHost (" {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++ @@ -4742,7 +6273,7 @@ function mtr { $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 + Write-PspHost (" {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 } @@ -4750,6 +6281,10 @@ function mtr { # Create an elevated firewall allow rule after confirmation. function fwallow { + <# + .SYNOPSIS + Quick firewall allow rule (supports -WhatIf/-Confirm). + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory)][string]$Name, @@ -4757,17 +6292,21 @@ function fwallow { [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 } + if (-not $script:PSP.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 + Write-PspHost ("Allow rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Green } # Create an elevated firewall block rule after confirmation. function fwblock { + <# + .SYNOPSIS + Quick firewall block rule (supports -WhatIf/-Confirm). + #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory)][string]$Name, @@ -4775,27 +6314,38 @@ function fwblock { [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 } + if (-not $script:PSP.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 + Write-PspHost ("Block rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Yellow } # Cybersec -# Run curated nmap profiles with custom ports in Ports mode. +# Run curated nmap profiles; the intrusive Full, Stealth and Vuln profiles ask first unless -Force. function nscan { - [CmdletBinding()] + <# + .SYNOPSIS + Nmap wrapper. + .EXAMPLE + nscan 192.168.1.10 + Quick scan of the top ports of a host you are authorized to scan. + .EXAMPLE + nscan 192.168.1.10 -Mode Services + Detect service versions. + #> + [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][string]$Target, [ValidateSet('Quick', 'Full', 'Services', 'Stealth', 'Vuln', 'Ports')] [string]$Mode = 'Quick', - [int[]]$Ports + [int[]]$Ports, + [switch]$Force ) - if (-not (Get-Command nmap -ErrorAction Ignore)) { + if (-not (Get-Command nmap -CommandType Application -ErrorAction Ignore)) { Write-Error 'nmap is not installed. Install with: winget install Insecure.Nmap' return } @@ -4808,7 +6358,16 @@ function nscan { 'Ports' { @() } } if ($Mode -eq 'Ports' -and $Ports) { $nmapArgs += @('-p', ($Ports -join ',')) } - Write-Host ("nmap {0} {1}" -f ($nmapArgs -join ' '), $Target) -ForegroundColor DarkGray + Write-PspHost 'Scan only systems you own or are explicitly authorized to test.' -ForegroundColor DarkYellow + if (-not $PSCmdlet.ShouldProcess($Target, "nmap $($nmapArgs -join ' ')")) { return } + if ($Mode -in 'Full', 'Stealth', 'Vuln' -and -not $Force) { + $go = $false + $why = if ($Mode -eq 'Vuln') { 'runs intrusive NSE scripts that can disrupt services' } else { 'is a heavy or evasive scan that monitoring will flag' } + try { $go = $PSCmdlet.ShouldContinue("The $Mode profile $why. Scan ${Target}?", 'nscan') } + catch { Write-Warning "nscan: cannot ask for confirmation in this session; re-run with -Force." } + if (-not $go) { return } + } + Write-PspHost ("nmap {0} {1}" -f ($nmapArgs -join ' '), $Target) -ForegroundColor DarkGray $oldTitle = Push-TabTitle "nscan $Mode`: $Target" try { nmap @nmapArgs $Target } finally { Pop-TabTitle $oldTitle } @@ -4816,10 +6375,14 @@ function nscan { # Report Authenticode status, signer, and expiry for files or directories. function sigcheck { + <# + .SYNOPSIS + Authenticode signature details. + #> 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) { + $targets = if ((Get-Item -LiteralPath $resolved.ProviderPath).PSIsContainer) { Get-ChildItem -LiteralPath $resolved -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Extension -match '^\.(exe|dll|sys|ps1|psm1|psd1|msi|cat|ocx|cpl)$' } } @@ -4830,31 +6393,35 @@ function sigcheck { $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 } + Write-PspHost '' + Write-PspHost ("File: {0}" -f $t.FullName) -ForegroundColor Cyan + Write-PspHost ("Status: {0}" -f $sig.Status) -ForegroundColor $color + if ($sig.StatusMessage) { Write-PspHost ("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 + Write-PspHost ("Signer: {0}" -f $sig.SignerCertificate.Subject) + Write-PspHost ("Issuer: {0}" -f $sig.SignerCertificate.Issuer) + Write-PspHost ("Expires: {0}" -f $sig.SignerCertificate.NotAfter) + Write-PspHost ("Thumb: {0}" -f $sig.SignerCertificate.Thumbprint) -ForegroundColor DarkGray } if ($sig.TimeStamperCertificate) { - Write-Host ("Timestamp: {0}" -f $sig.TimeStamperCertificate.Subject) -ForegroundColor DarkGray + Write-PspHost ("Timestamp: {0}" -f $sig.TimeStamperCertificate.Subject) -ForegroundColor DarkGray } } } # List NTFS alternate data streams for files or directories. function ads { + <# + .SYNOPSIS + Alternate data streams. + #> 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 + $items = if ((Get-Item -LiteralPath $resolved.ProviderPath).PSIsContainer) { + Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -File -ErrorAction SilentlyContinue } - else { Get-Item -LiteralPath $resolved } + else { Get-Item -LiteralPath $resolved.ProviderPath } # Emit structured objects that remain readable with default formatting. foreach ($item in $items) { $streams = Get-Item -LiteralPath $item.FullName -Stream * -ErrorAction SilentlyContinue | @@ -4871,6 +6438,10 @@ function ads { # Run quick, full, or path-specific Windows Defender scans. function defscan { + <# + .SYNOPSIS + Defender scan wrapper. + #> [CmdletBinding()] param( [Parameter(Position = 0)][string]$Path, @@ -4886,27 +6457,45 @@ function defscan { 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 + Write-PspHost ("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 + Write-PspHost ("Running {0}..." -f $scanType) -ForegroundColor Cyan Start-MpScan -ScanType $scanType } $threats = Get-MpThreat -ErrorAction SilentlyContinue if ($threats) { - Write-Host 'Recent threats:' -ForegroundColor Yellow + Write-PspHost 'Recent threats:' -ForegroundColor Yellow $threats | Select-Object -First 10 | Format-Table ThreatName, SeverityID, DetectionID -AutoSize } - else { Write-Host 'No threats recorded.' -ForegroundColor Green } + else { Write-PspHost 'No threats recorded.' -ForegroundColor Green } } finally { Pop-TabTitle $oldTitle } } # Check a password through HIBP k-anonymity using only five SHA1 characters. function pwnd { - param([Parameter(Mandatory)][string]$Candidate) + <# + .SYNOPSIS + HIBP breach check. + .EXAMPLE + pwnd + Prompt for a password without echo and check it against Have I Been Pwned. + #> + # Omit -Candidate to be prompted: a password typed as an argument stays in scrollback and Get-History. + param([string]$Candidate) + if (-not $Candidate) { + $secure = Read-Host -Prompt 'Password to check (not echoed)' -AsSecureString + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + try { $Candidate = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } + finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } + if (-not $Candidate) { Write-Error 'No password entered.'; return } + } + else { + Write-Warning 'The password you typed is now in this session''s scrollback and Get-History; run pwnd without arguments to be prompted instead.' + } $sha1 = [System.Security.Cryptography.SHA1]::Create() try { $hashBytes = $sha1.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Candidate)) @@ -4922,11 +6511,11 @@ function pwnd { 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 + Write-PspHost ("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 + Write-PspHost 'Safe: password not found in known breaches.' -ForegroundColor Green } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -4936,45 +6525,51 @@ function pwnd { # Extend tlscert with protocol, cipher, chain, SAN, and SHA256 details. function certcheck { + <# + .SYNOPSIS + TLS cert chain + pinning. + .EXAMPLE + certcheck example.com + Show the certificate chain, SANs, pin and any validation errors. + #> param( [Parameter(Mandatory)][string]$Domain, [int]$Port = 443 ) - $tcp = $null; $ssl = $null; $chain = $null + $session = $null; $chain = $null; $cert = $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) + $session = Get-PspTlsSession -Domain $Domain -Port $Port + $ssl = $session.Ssl $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ssl.RemoteCertificate) $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() $chainValid = $chain.Build($cert) + $problems = Format-PspTlsErrors -Errors $session.Errors -Domain $Domain + $nameOk = -not ($session.Errors -band [System.Net.Security.SslPolicyErrors]::RemoteCertificateNameMismatch) $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' }) + Write-PspHost ("Host: {0}:{1}" -f $Domain, $Port) -ForegroundColor Cyan + Write-PspHost ("TLS: {0}" -f $ssl.SslProtocol) + Write-PspHost ("Cipher: {0} {1} bits" -f $ssl.CipherAlgorithm, $ssl.CipherStrength) + Write-PspHost ("Subject: {0}" -f $cert.Subject) + Write-PspHost ("Issuer: {0}" -f $cert.Issuer) + Write-PspHost ("Serial: {0}" -f $cert.SerialNumber) -ForegroundColor DarkGray + Write-PspHost ("SHA256 pin: {0}" -f $cert.GetCertHashString('SHA256')) -ForegroundColor DarkGray + Write-PspHost ("Not before: {0}" -f $cert.NotBefore) + Write-PspHost ("Not after: {0}" -f $cert.NotAfter) + Write-PspHost ("Days left: {0}" -f $daysLeft) -ForegroundColor $expiryColor + Write-PspHost ("Chain valid: {0}" -f $chainValid) -ForegroundColor $(if ($chainValid) { 'Green' } else { 'Red' }) + Write-PspHost ("Name match: {0}" -f $nameOk) -ForegroundColor $(if ($nameOk) { 'Green' } else { 'Red' }) + if ($problems) { Write-PspHost ("Validation: FAILED - {0}" -f $problems) -ForegroundColor Red } + else { Write-PspHost 'Validation: OK' -ForegroundColor Green } $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-PspHost ("SAN: {0}" -f $san) -ForegroundColor DarkGray } - Write-Host 'Chain:' -ForegroundColor DarkGray + Write-PspHost '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 + Write-PspHost (" {0} (exp {1})" -f $ec.Subject, $ec.NotAfter.ToString('yyyy-MM-dd')) -ForegroundColor DarkGray } } catch { @@ -4983,18 +6578,22 @@ function certcheck { } finally { if ($chain) { $chain.Dispose() } - if ($ssl) { $ssl.Dispose() } - if ($tcp) { $tcp.Dispose() } + if ($cert) { $cert.Dispose() } + if ($session) { $session.Ssl.Dispose(); $session.Tcp.Dispose() } } } # Report file entropy from 0.0 to 8.0 as an indicator of packing or encryption. function entropy { + <# + .SYNOPSIS + Shannon 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 } + if ($bytes.Length -eq 0) { Write-PspHost 'Empty file.' -ForegroundColor Yellow; return } $counts = New-Object 'int[]' 256 foreach ($b in $bytes) { $counts[$b]++ } $e = 0.0 @@ -5009,8 +6608,8 @@ function entropy { 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 + Write-PspHost ("File: {0} ({1} bytes)" -f (Split-Path $resolved.Path -Leaf), $bytes.Length) -ForegroundColor Cyan + Write-PspHost ("Entropy: {0:N3} / 8.000 ({1})" -f $e, $label) -ForegroundColor $color } # Developer @@ -5018,6 +6617,13 @@ function entropy { # Serve a directory through Python or npx. Loopback only by default: both servers # listen on every interface unless told otherwise. Use -Bind 0.0.0.0 to share on the LAN. function serve { + <# + .SYNOPSIS + One-line HTTP server. + .EXAMPLE + serve 8080 + Serve the current folder on http://127.0.0.1:8080. + #> param( [int]$Port = 8000, [string]$Path = '.', @@ -5027,17 +6633,25 @@ function serve { if (-not $resolved) { Write-Error "Path not found: $Path"; return } $oldTitle = Push-TabTitle "serve :$Port" try { - if (Get-Command python -ErrorAction Ignore) { - Write-Host ("Serving {0} on http://{1}:{2} (Ctrl+C to stop)" -f $resolved.Path, $Bind, $Port) -ForegroundColor Cyan - Push-Location $resolved.Path + # The Microsoft Store 'python' alias exists even when Python is not installed; it only opens the Store. + $hasPython = $false + if (Get-Command python -CommandType Application -ErrorAction Ignore) { + $null = & python -c "import http.server" 2>$null + $hasPython = $LASTEXITCODE -eq 0 + } + if ($hasPython) { + Write-PspHost ("Serving {0} on http://{1}:{2} (Ctrl+C to stop)" -f $resolved.Path, $Bind, $Port) -ForegroundColor Cyan + Push-Location -LiteralPath $resolved.Path try { & python -m http.server $Port --bind $Bind } finally { Pop-Location } return } if (Get-Command npx -ErrorAction Ignore) { - Write-Host ("Serving {0} via npx http-server on http://{1}:{2}" -f $resolved.Path, $Bind, $Port) -ForegroundColor Cyan - Push-Location $resolved.Path - try { & npx --yes http-server -p $Port -a $Bind } + # Pinned so npx never fetches and runs whatever version was published last. + $httpServer = 'http-server@' + (Get-PspComponentPins).HttpServer + Write-PspHost ("Serving {0} via npx {1} on http://{2}:{3}" -f $resolved.Path, $httpServer, $Bind, $Port) -ForegroundColor Cyan + Push-Location -LiteralPath $resolved.Path + try { & npx --yes $httpServer -p $Port -a $Bind } finally { Pop-Location } return } @@ -5048,19 +6662,23 @@ function serve { # Generate a multi-language .gitignore through the Toptal API. function gitignore { + <# + .SYNOPSIS + Generate .gitignore from gitignore.io. + #> 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) { + $target = Join-Path (Get-Location).ProviderPath '.gitignore' + if (Test-Path -LiteralPath $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 + Copy-Item -LiteralPath $target -Destination $backup -ErrorAction Stop + Write-PspHost ("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 + Write-PspHost ("Wrote .gitignore for: {0}" -f $joined) -ForegroundColor Green } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -5070,6 +6688,10 @@ function gitignore { # Check out local or remote Git branches through fzf. function gcof { + <# + .SYNOPSIS + Fuzzy git branch checkout (fzf). + #> if (-not (Get-Command git -ErrorAction Ignore)) { Write-Error 'git is not installed'; return } if (-not (Get-Command fzf -ErrorAction Ignore)) { 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' } @@ -5083,26 +6705,36 @@ function gcof { # Load exported or quoted .env values into the current session. function envload { + <# + .SYNOPSIS + Load .env file. + #> 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 { + Get-Content -LiteralPath $resolved.ProviderPath -Encoding UTF8 | 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) } + # Unquoted values end at an inline comment, as in dotenv. + else { $v = ($v -replace '\s+#.*$', '').Trim() } Set-Item -LiteralPath "env:$k" -Value $v $loaded++ } } - Write-Host ("Loaded {0} variable(s) from {1}" -f $loaded, $resolved.Path) -ForegroundColor Green + Write-PspHost ("Loaded {0} variable(s) from {1}" -f $loaded, $resolved.Path) -ForegroundColor Green } # Look up command examples through an installed tldr client or tldr-pages. function tldr { + <# + .SYNOPSIS + Quick command examples. + #> if (Get-Command tldr.exe -ErrorAction Ignore) { & tldr.exe @args; return } if (-not $args) { Write-Error 'Usage: tldr '; return } $cmd = ($args[0]).ToString().ToLower() @@ -5110,7 +6742,7 @@ function tldr { 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 + Write-PspHost $page -ForegroundColor White return } catch { continue } @@ -5120,6 +6752,10 @@ function tldr { # Run a scriptblock repeatedly with optional success-based termination. function repeat { + <# + .SYNOPSIS + Repeat scriptblock N times. + #> param( [Parameter(Mandatory, Position = 0)][ValidateRange(1, 10000)][int]$Count, [Parameter(Mandatory, Position = 1)][scriptblock]$Command, @@ -5132,7 +6768,7 @@ function repeat { 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 + Write-PspHost ("[{0}/{1}]" -f $i, $Count) -ForegroundColor DarkGray # Clear stale native exit codes before evaluating -UntilSuccess. $global:LASTEXITCODE = $null $threwError = $false @@ -5141,11 +6777,11 @@ function repeat { } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } - Write-Host $_.Exception.Message -ForegroundColor Red + Write-PspHost $_.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 + Write-PspHost ("Success on attempt {0}." -f $i) -ForegroundColor Green return } if ($DelaySeconds -gt 0 -and $i -lt $Count) { Start-Sleep -Seconds $DelaySeconds } @@ -5157,6 +6793,10 @@ function repeat { # Create and activate a Python virtual environment. function mkvenv { + <# + .SYNOPSIS + Create + activate Python venv. + #> param( [string]$Name = '.venv', [string]$PythonPath @@ -5166,7 +6806,7 @@ function mkvenv { elseif (Get-Command py -ErrorAction Ignore) { '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 + Write-PspHost ("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 @@ -5174,7 +6814,7 @@ function mkvenv { 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 + Write-PspHost ("Activated {0}." -f $Name) -ForegroundColor Green } else { Write-Warning "Created $Name but Activate.ps1 not found." } } @@ -5183,11 +6823,15 @@ function mkvenv { # Emit function and alias outline entries from a PowerShell file. function outline { + <# + .SYNOPSIS + AST outline of a .ps1 file. + #> 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) + $ast = [System.Management.Automation.Language.Parser]::ParseFile($resolved.ProviderPath, [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) @@ -5205,7 +6849,7 @@ function outline { Params = ($paramNames -join ', ') } } - $raw = Get-Content $resolved.Path -Raw + $raw = Get-Utf8FileText $resolved.ProviderPath $aliases = [regex]::Matches($raw, 'Set-Alias\s+-Name\s+(\S+)\s+-Value\s+(\S+)') foreach ($m in $aliases) { [PSCustomObject]@{ @@ -5219,6 +6863,10 @@ function outline { # Search PowerShell function names through the AST. function psym { + <# + .SYNOPSIS + Symbol search across .ps1 files. + #> param( [Parameter(Position = 0)][string]$Pattern, [Parameter(Position = 1)][string]$Root = '.' @@ -5243,12 +6891,16 @@ function psym { } } } - if ($results.Count -eq 0) { Write-Host 'No matches.' -ForegroundColor Yellow; return } + if ($results.Count -eq 0) { Write-PspHost 'No matches.' -ForegroundColor Yellow; return } $results | Sort-Object Name | Format-Table Name, Line, File -AutoSize } # Run PSScriptAnalyzer presets with optional automatic fixes. function lint { + <# + .SYNOPSIS + PSScriptAnalyzer wrapper with presets. + #> [CmdletBinding()] param( [Parameter(Position = 0)][string]$Path = '.', @@ -5286,32 +6938,36 @@ function lint { } if ($Fix) { $splat.Fix = $true } $results = Invoke-ScriptAnalyzer @splat - if (-not $results) { Write-Host 'No issues found.' -ForegroundColor Green; return } + if (-not $results) { Write-PspHost 'No issues found.' -ForegroundColor Green; return } $byRule = $results | Group-Object RuleName | Sort-Object Count -Descending - Write-Host '' + Write-PspHost '' foreach ($g in $byRule) { - Write-Host ("[{0}] {1}" -f $g.Count, $g.Name) -ForegroundColor Cyan + Write-PspHost ("[{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-PspHost (" {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 + Write-PspHost '' + Write-PspHost ("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 { + <# + .SYNOPSIS + Unused params / uncalled fns. + #> [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) + $ast = [System.Management.Automation.Language.Parser]::ParseFile($resolved.ProviderPath, [ref]$tokens, [ref]$parseErrors) $fns = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) - Write-Host 'Unused parameters:' -ForegroundColor Cyan + Write-PspHost 'Unused parameters:' -ForegroundColor Cyan $anyUnused = $false foreach ($f in $fns) { $params = @() @@ -5325,67 +6981,76 @@ function Find-DeadCode { $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 + Write-PspHost (" {0} L{1}: `${2}" -f $f.Name, $p.Extent.StartLineNumber, $name) -ForegroundColor Yellow $anyUnused = $true } } } - if (-not $anyUnused) { Write-Host ' (none)' -ForegroundColor DarkGray } + if (-not $anyUnused) { Write-PspHost ' (none)' -ForegroundColor DarkGray } - Write-Host '' - Write-Host 'Possibly uncalled functions (same-file check):' -ForegroundColor Cyan + Write-PspHost '' + Write-PspHost '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 + Write-PspHost (" {0} (L{1})" -f $f.Name, $f.Extent.StartLineNumber) -ForegroundColor Yellow $anyUncalled = $true } } - if (-not $anyUncalled) { Write-Host ' (none)' -ForegroundColor DarkGray } + if (-not $anyUncalled) { Write-PspHost ' (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)) + <# + .SYNOPSIS + Profile diagnostics. + #> + Write-PspHost 'Profile Diagnostics' -ForegroundColor Cyan + Write-PspHost '' + Write-PspHost (" PS Version: {0} ({1})" -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition) + Write-PspHost (" Language Mode: {0}" -f $ExecutionContext.SessionState.LanguageMode) + Write-PspHost (" Execution Policy: {0}" -f (Get-ExecutionPolicy)) + Write-PspHost (" Profile Path: {0}" -f $PROFILE) + Write-PspHost (" 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' })) + Write-PspHost (" 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) + Write-PspHost (" Cache Dir: {0}" -f $cache) if (Test-Path $cache) { $cacheFiles = Get-ChildItem $cache -File -ErrorAction SilentlyContinue - Write-Host (" Cache Files: {0}" -f $cacheFiles.Count) + Write-PspHost (" 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-PspHost (" {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 + Write-PspHost (" Modules Loaded: {0}" -f (Get-Module).Count) + Write-PspHost '' + Write-PspHost 'Managed Tools:' -ForegroundColor Cyan foreach ($tool in $script:ProfileTools) { $found = Get-Command $tool.Cmd -ErrorAction Ignore $status = if ($found) { 'OK' } else { 'MISSING' } $color = if ($found) { 'Green' } else { 'Yellow' } - Write-Host (" {0,-14} {1}" -f $tool.Cmd, $status) -ForegroundColor $color + Write-PspHost (" {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) + Write-PspHost '' + Write-PspHost 'Environment:' -ForegroundColor Cyan + Write-PspHost (" Profile Version: {0}" -f $script:PSP.Version) + Write-PspHost (" Interactive: {0}" -f $script:PSP.IsInteractive) + Write-PspHost (" Admin: {0}" -f $script:PSP.IsAdmin) + Write-PspHost (" CI: {0}" -f [bool]$env:CI) + Write-PspHost (" AI_AGENT: {0}" -f [bool]$env:AI_AGENT) } # Enumerate every installed PowerShell (5.1 Desktop + all Core/7+ locations). function Get-PwshVersions { + <# + .SYNOPSIS + All installed PowerShell versions. + #> $found = [System.Collections.Generic.List[object]]::new() $ps5 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' if (Test-Path $ps5) { @@ -5413,6 +7078,10 @@ function Get-PwshVersions { # Emit metadata for every installed version of a module. function modinfo { + <# + .SYNOPSIS + Module details. + #> 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 } @@ -5445,6 +7114,10 @@ function modinfo { # Search structural PowerShell patterns with an optional AST kind. function psgrep { + <# + .SYNOPSIS + AST-based code search. + #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)][string]$Pattern, @@ -5471,7 +7144,7 @@ function psgrep { 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) + Write-PspHost ("{0}:{1}: {2}" -f $file, $h.Extent.StartLineNumber, $snippet) } } } @@ -5527,61 +7200,59 @@ function Set-PspFeatureOverride { } # Configure PSReadLine from theme defaults and user color overrides. -$_readlineColors = $null +$_pspReadlineColors = $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 + $_pspThemeConfig = Read-JsonFile (Join-Path $cacheDir 'theme.json') + if ($_pspThemeConfig) { + if ($_pspThemeConfig.psreadline -and $_pspThemeConfig.psreadline.colors) { + $_pspReadlineColors = @{} + foreach ($_pspProp in $_pspThemeConfig.psreadline.colors.PSObject.Properties) { + $_pspReadlineColors[$_pspProp.Name] = $_pspProp.Value } } } # Merge user-settings.json.psreadline.colors on top so wizard/manual overrides win. - $_userSettingsForRL = Join-Path $cacheDir 'user-settings.json' - if (Test-Path $_userSettingsForRL) { - $_rlUser = Get-Utf8FileText $_userSettingsForRL | ConvertFrom-Json -ErrorAction Stop + $_pspRlUser = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') + if ($_pspRlUser) { # Apply feature toggles before initializing their load-time consumers. - Set-PspFeatureOverride $_rlUser - if ($_rlUser.psreadline -and $_rlUser.psreadline.colors) { - if ($null -eq $_readlineColors) { $_readlineColors = @{} } - foreach ($prop in $_rlUser.psreadline.colors.PSObject.Properties) { - $_readlineColors[$prop.Name] = $prop.Value + Set-PspFeatureOverride $_pspRlUser + if ($_pspRlUser.psreadline -and $_pspRlUser.psreadline.colors) { + if ($null -eq $_pspReadlineColors) { $_pspReadlineColors = @{} } + foreach ($_pspProp in $_pspRlUser.psreadline.colors.PSObject.Properties) { + $_pspReadlineColors[$_pspProp.Name] = $_pspProp.Value } } } } catch { $null = $_ } -$PSReadLineOptions = @{ +$_pspPSReadLineOptions = @{ EditMode = 'Windows' HistoryNoDuplicates = $true HistorySearchCursorMovesToEnd = $true BellStyle = 'None' } -if ($_readlineColors) { $PSReadLineOptions.Colors = $_readlineColors } +if ($_pspReadlineColors) { $_pspPSReadLineOptions.Colors = $_pspReadlineColors } # Retry without custom colors if PSReadLine rejects them. -try { Set-PSReadLineOption @PSReadLineOptions } +try { Set-PSReadLineOption @_pspPSReadLineOptions } 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)" } + $_pspPSReadLineOptions.Remove('Colors') + try { Set-PSReadLineOption @_pspPSReadLineOptions } catch { Write-Warning "PSReadLine base options failed: $($_.Exception.Message)" } } # PSReadLine features that require an interactive console host -if ($isInteractive -and (Get-Module PSReadLine)) { +if ($script:PSP.IsInteractive -and (Get-Module PSReadLine)) { # Enable Core-only predictions on VT-capable hosts unless disabled in user-settings.json. if ($PSVersionTable.PSEdition -eq "Core" -and $script:PSP.Features.predictions) { - $supportsPrediction = $false + $_pspSupportsPrediction = $false try { - $supportsPrediction = [bool]$Host.UI.SupportsVirtualTerminal -and -not [Console]::IsOutputRedirected + $_pspSupportsPrediction = [bool]$Host.UI.SupportsVirtualTerminal -and -not [Console]::IsOutputRedirected } catch { - $supportsPrediction = $false + $_pspSupportsPrediction = $false } - if ($supportsPrediction) { + if ($_pspSupportsPrediction) { try { Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction Stop Set-PSReadLineOption -PredictionViewStyle ListView -ErrorAction Stop @@ -5604,11 +7275,11 @@ if ($isInteractive -and (Get-Module PSReadLine)) { Set-PSReadLineKeyHandler -Chord 'Ctrl+RightArrow' -Function ForwardWord Set-PSReadLineKeyHandler -Chord 'Ctrl+z' -Function Undo Set-PSReadLineKeyHandler -Chord 'Ctrl+y' -Function Redo - $smartPasteHandler = { + $_pspSmartPasteHandler = { 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 + Set-PSReadLineKeyHandler -Chord 'Alt+v' -BriefDescription SmartPaste -Description 'Paste clipboard as one block into prompt' -ScriptBlock $_pspSmartPasteHandler # Collapse accepted prompts when the customizable transientPrompt feature is enabled. if ($script:PSP.Features.transientPrompt) { @@ -5633,8 +7304,8 @@ if ($isInteractive -and (Get-Module PSReadLine)) { } # Enable PSFzf history and file search unless disabled in user-settings.json. - if ($script:PSP.Features.psfzf -and (Get-Command fzf -ErrorAction Ignore)) { - if (-not $env:FZF_DEFAULT_COMMAND -and (Get-Command rg -ErrorAction Ignore)) { + if ($script:PSP.Features.psfzf -and (Get-Command fzf -CommandType Application -ErrorAction Ignore)) { + if (-not $env:FZF_DEFAULT_COMMAND -and (Get-Command rg -CommandType Application -ErrorAction Ignore)) { $env:FZF_DEFAULT_COMMAND = 'rg --files --hidden --glob "!.git"' } if (-not $env:FZF_DEFAULT_OPTS) { @@ -5655,36 +7326,36 @@ if ($isInteractive -and (Get-Module PSReadLine)) { } # Cache native completion scripts to avoid launching each tool during shell startup. - $_nativeCompleters = @( + $_pspNativeCompleters = @( @{ 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 Ignore)) { continue } - $_ncCachePath = Join-Path $cacheDir $_nc.Cache - $_ncReady = (Test-Path $_ncCachePath) -and ((Get-Item $_ncCachePath -ErrorAction SilentlyContinue).Length -gt 0) - if (-not $_ncReady) { + foreach ($_pspNc in $_pspNativeCompleters) { + if (-not (Get-Command $_pspNc.Cmd -CommandType Application -ErrorAction Ignore)) { continue } + $_pspNcCachePath = Join-Path $cacheDir $_pspNc.Cache + $_pspNcReady = (Test-Path -LiteralPath $_pspNcCachePath) -and ((Get-Item -LiteralPath $_pspNcCachePath -ErrorAction SilentlyContinue).Length -gt 0) + if (-not $_pspNcReady) { 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 + $_pspNcOutput = & $_pspNc.Cmd @($_pspNc.Args) 2>$null | Out-String + if ($_pspNcOutput -and $_pspNcOutput.Trim().Length -gt 0) { + [System.IO.File]::WriteAllText($_pspNcCachePath, $_pspNcOutput, [System.Text.UTF8Encoding]::new($false)) + $_pspNcReady = $true } } catch { $null = $_ } } - if ($_ncReady) { - try { . $_ncCachePath } + if ($_pspNcReady) { + try { . $_pspNcCachePath } catch { # Corrupt cache; delete so next load regenerates. - Remove-Item -LiteralPath $_ncCachePath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $_pspNcCachePath -Force -ErrorAction SilentlyContinue } } } # Prepend active environment, cloud, Kubernetes, and job indicators to the tab title. - $script:PSP.BaseTitle = "PowerShell {0}{1}" -f $PSVersionTable.PSVersion, $adminSuffix + $script:PSP.BaseTitle = "PowerShell {0}{1}" -f $PSVersionTable.PSVersion, $script:PSP.AdminSuffix Register-ProfileHook -EventName PrePrompt -Action { try { $parts = @() @@ -5692,18 +7363,24 @@ if ($isInteractive -and (Get-Module PSReadLine)) { 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) { + # Kubernetes context from the kubeconfig (first KUBECONFIG entry), re-read only when the file changes. + $kubeConfig = if ($env:KUBECONFIG) { @($env:KUBECONFIG -split ';' | Where-Object { $_ })[0] } else { Join-Path $HOME '.kube/config' } + if ($kubeConfig -and (Test-Path -LiteralPath $kubeConfig -PathType Leaf)) { + $kubeItem = Get-Item -LiteralPath $kubeConfig -Force + $stamp = '{0}|{1}|{2}' -f $kubeItem.FullName, $kubeItem.LastWriteTimeUtc.Ticks, $kubeItem.Length + $kubeCache = $script:PSP.KubeContextCache + if (-not $kubeCache -or $kubeCache.Stamp -ne $stamp) { + $kubeCtx = $null + try { + $match = Select-String -LiteralPath $kubeItem.FullName -Pattern '^current-context:\s*(.+)$' -ErrorAction Stop | Select-Object -First 1 # Remove YAML quotes from the Kubernetes context name. - $kubeCtx = $match.Matches[0].Groups[1].Value.Trim().Trim('"').Trim("'") - if ($kubeCtx) { $parts += "k8s:$kubeCtx" } + if ($match) { $kubeCtx = $match.Matches[0].Groups[1].Value.Trim().Trim('"').Trim("'") } } + catch { $null = $_ } + $kubeCache = @{ Stamp = $stamp; Context = $kubeCtx } + $script:PSP.KubeContextCache = $kubeCache } - catch { $null = $_ } + if ($kubeCache.Context) { $parts += "k8s:$($kubeCache.Context)" } } $jobCount = @(Get-Job -State Running -ErrorAction SilentlyContinue).Count if ($jobCount -gt 0) { $parts += "jobs:$jobCount" } @@ -5717,7 +7394,7 @@ if ($isInteractive -and (Get-Module PSReadLine)) { } # Custom completion for common commands -$scriptblock = { +$_pspScriptblock = { param($wordToComplete, $commandAst, $cursorPosition) $customCompletions = @{ 'git' = @('status', 'add', 'commit', 'push', 'pull', 'clone', 'checkout') @@ -5733,77 +7410,66 @@ $scriptblock = { } } } -Register-ArgumentCompleter -Native -CommandName git, npm, deno -ScriptBlock $scriptblock +Register-ArgumentCompleter -Native -CommandName git, npm, deno -ScriptBlock $_pspScriptblock # dotnet completion (only if dotnet is installed) -if (Get-Command dotnet -ErrorAction Ignore) { - $dotnetScriptblock = { +if (Get-Command dotnet -CommandType Application -ErrorAction Ignore) { + $_pspDotnetScriptblock = { 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 + Register-ArgumentCompleter -Native -CommandName dotnet -ScriptBlock $_pspDotnetScriptblock } # Oh My Posh initialization (interactive only; render with explicit --config every prompt) -if ($isInteractive) { - $ompExecutablePath = Get-OhMyPoshExecutablePath - if ($ompExecutablePath) { +if ($script:PSP.IsInteractive) { + $_pspOmpExecutablePath = Get-OhMyPoshExecutablePath + if ($_pspOmpExecutablePath) { # 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: $_" } + $_pspThemeName = $null + try { + $_pspCfg = Read-JsonFile (Join-Path $cacheDir 'theme.json') + if ($_pspCfg -and $_pspCfg.theme -and $_pspCfg.theme.name) { $_pspThemeName = $_pspCfg.theme.name } } - - $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: $_" } + catch { Write-Verbose "Failed to parse theme.json: $_" } + try { + $_pspUserCfg = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') + if ($_pspUserCfg -and $_pspUserCfg.theme -and $_pspUserCfg.theme.name) { $_pspThemeName = $_pspUserCfg.theme.name } } + catch { Write-Verbose "Failed to parse user-settings.json: $_" } - if (-not $themeName) { + if (-not $_pspThemeName) { 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)) { + $_pspLocalThemePath = if ($_pspThemeName) { Join-Path $cacheDir "$_pspThemeName.omp.json" } else { $null } + if ($_pspLocalThemePath -and -not (Test-Path -LiteralPath $_pspLocalThemePath)) { # Startup recovery is limited to local theme files. - $oldThemePath = Join-Path (Split-Path $PROFILE) "$themeName.omp.json" - if (Test-Path $oldThemePath) { - try { Move-Item $oldThemePath $localThemePath -Force -ErrorAction Stop } + $_pspOldThemePath = Join-Path (Split-Path $PROFILE) "$_pspThemeName.omp.json" + if (Test-Path -LiteralPath $_pspOldThemePath) { + try { Move-Item -LiteralPath $_pspOldThemePath -Destination $_pspLocalThemePath -Force -ErrorAction Stop } catch { Write-Warning "Could not migrate theme from Documents: $_" } } } - if ($localThemePath -and (Test-Path $localThemePath)) { + if ($_pspLocalThemePath -and (Test-Path -LiteralPath $_pspLocalThemePath)) { try { - $themeContent = Get-Content $localThemePath -Raw -ErrorAction Stop - if ([string]::IsNullOrWhiteSpace($themeContent)) { throw 'Theme file is empty' } - $null = $themeContent | ConvertFrom-Json + if ($null -eq (Read-JsonFile $_pspLocalThemePath)) { throw 'Theme file is empty' } } catch { - Write-Warning "Configured OMP theme is invalid at '$localThemePath': $_" - $localThemePath = $null + Write-Warning "Configured OMP theme is invalid at '$_pspLocalThemePath': $_" + $_pspLocalThemePath = $null } } - if ($localThemePath -and (Test-Path $localThemePath)) { + if ($_pspLocalThemePath -and (Test-Path -LiteralPath $_pspLocalThemePath)) { try { - $null = Get-Content $localThemePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - # Render with the validated local theme. - $script:OhMyPoshExecutablePath = $ompExecutablePath - $script:OhMyPoshConfigPath = $localThemePath + $script:OhMyPoshExecutablePath = $_pspOmpExecutablePath + $script:OhMyPoshConfigPath = $_pspLocalThemePath $script:OhMyPoshLastHistoryId = $null $env:VIRTUAL_ENV_DISABLE_PROMPT = 1 $env:PYENV_VIRTUALENV_DISABLE_PROMPT = 1 @@ -5814,12 +7480,12 @@ if ($isInteractive) { try { if (Get-Module PSReadLine -ErrorAction SilentlyContinue) { - $secondaryPrompt = Get-OhMyPoshPromptText ` + $_pspSecondaryPrompt = Get-OhMyPoshPromptText ` -Type 'secondary' ` -ExecutablePath $script:OhMyPoshExecutablePath ` -ConfigPath $script:OhMyPoshConfigPath - if (-not [string]::IsNullOrWhiteSpace($secondaryPrompt)) { - Set-PSReadLineOption -ContinuationPrompt ($secondaryPrompt -join "`n") + if (-not [string]::IsNullOrWhiteSpace($_pspSecondaryPrompt)) { + Set-PSReadLineOption -ContinuationPrompt ($_pspSecondaryPrompt -join "`n") } } } @@ -5839,8 +7505,9 @@ if ($isInteractive) { -OriginalSuccess:$originalSuccess ` -OriginalLastExitCode $originalLastExitCode + # The prompt stage already ran above, so fall back to the text alone. if ([string]::IsNullOrWhiteSpace($output)) { - return & $script:FallbackPromptFunction + return Get-PspPlainPrompt } try { @@ -5856,7 +7523,7 @@ if ($isInteractive) { } catch { Write-Warning "Failed to render oh-my-posh prompt with '$($script:OhMyPoshConfigPath)': $_" - return & $script:FallbackPromptFunction + return Get-PspPlainPrompt } finally { $global:LASTEXITCODE = $originalLastExitCode @@ -5864,11 +7531,11 @@ if ($isInteractive) { } } catch { - Write-Warning "Failed to initialize oh-my-posh with '$localThemePath': $_" + Write-Warning "Failed to initialize oh-my-posh with '$_pspLocalThemePath': $_" } } - elseif ($themeName) { - Write-Warning "Configured OMP theme '$themeName' was not found locally at '$localThemePath'. Run Update-Profile or setup.ps1 to restore it." + elseif ($_pspThemeName) { + Write-Warning "Configured OMP theme '$_pspThemeName' was not found locally at '$_pspLocalThemePath'. Run Update-Profile or setup.ps1 to restore it." } } else { @@ -5877,63 +7544,63 @@ if ($isInteractive) { } # zoxide initialization (interactive only, cached for fast startup) -if ($isInteractive) { - $zoxideExecutablePath = Get-ExternalCommandPath -CommandName 'zoxide' - if ($zoxideExecutablePath) { - $zoxideCachePath = Join-Path $cacheDir "zoxide-init.ps1" +if ($script:PSP.IsInteractive) { + $_pspZoxideExecutablePath = Get-ExternalCommandPath -CommandName 'zoxide' + if ($_pspZoxideExecutablePath) { + $_pspZoxideCachePath = Join-Path $cacheDir "zoxide-init.ps1" # Reuse a non-empty zoxide cache and time out regeneration. - $cacheValid = $false - if (Test-Path $zoxideCachePath) { + $_pspCacheValid = $false + if (Test-Path $_pspZoxideCachePath) { try { - $fileSize = (Get-Item $zoxideCachePath).Length - if ($fileSize -gt 0) { - $cacheContent = Get-Content $zoxideCachePath -First 1 -ErrorAction Stop - if ($cacheContent -match '^# ZOXIDE_CACHE_VERSION: .+') { $cacheValid = $true } + $_pspFileSize = (Get-Item $_pspZoxideCachePath).Length + if ($_pspFileSize -gt 0) { + $_pspCacheContent = Get-Content $_pspZoxideCachePath -First 1 -ErrorAction Stop + if ($_pspCacheContent -match '^# ZOXIDE_CACHE_VERSION: .+') { $_pspCacheValid = $true } } } catch { Write-Verbose "Zoxide cache read failed (will regenerate)." } - if (-not $cacheValid) { Remove-Item $zoxideCachePath -Force -ErrorAction SilentlyContinue } + if (-not $_pspCacheValid) { Remove-Item $_pspZoxideCachePath -Force -ErrorAction SilentlyContinue } } - if (-not $cacheValid) { - $zoxideVersionResult = Invoke-WithTimeout -ScriptBlock { + if (-not $_pspCacheValid) { + $_pspZoxideVersionResult = Invoke-WithTimeout -ScriptBlock { param($exePath) (& $exePath --version 2>$null | Out-String).Trim() - } -ArgumentList @($zoxideExecutablePath) -TimeoutSec 5 - $zoxideVersion = if ($zoxideVersionResult) { $zoxideVersionResult } else { 'unknown' } - $initScript = Invoke-WithTimeout -ScriptBlock { + } -ArgumentList @($_pspZoxideExecutablePath) -TimeoutSec 5 + $_pspZoxideVersion = if ($_pspZoxideVersionResult) { $_pspZoxideVersionResult } else { 'unknown' } + $_pspInitScript = Invoke-WithTimeout -ScriptBlock { param($exePath) (& $exePath init --cmd z powershell | Out-String) - } -ArgumentList @($zoxideExecutablePath) -TimeoutSec 10 - if ($initScript) { - $zoxideInitStr = @($initScript) -join "`n" - $zoxideHeader = "# ZOXIDE_CACHE_VERSION: $zoxideVersion" - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($zoxideCachePath, ($zoxideHeader + "`n" + $zoxideInitStr), $utf8NoBom) + } -ArgumentList @($_pspZoxideExecutablePath) -TimeoutSec 10 + if ($_pspInitScript) { + $_pspZoxideInitStr = @($_pspInitScript) -join "`n" + $_pspZoxideHeader = "# ZOXIDE_CACHE_VERSION: $_pspZoxideVersion" + $_pspUtf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($_pspZoxideCachePath, ($_pspZoxideHeader + "`n" + $_pspZoxideInitStr), $_pspUtf8NoBom) } else { Write-Warning "zoxide init timed out or produced no output. Cache not written." } } try { - . $zoxideCachePath + . $_pspZoxideCachePath } catch { - Remove-Item $zoxideCachePath -Force -ErrorAction SilentlyContinue - $zoxideVersionResult = Invoke-WithTimeout -ScriptBlock { + Remove-Item $_pspZoxideCachePath -Force -ErrorAction SilentlyContinue + $_pspZoxideVersionResult = Invoke-WithTimeout -ScriptBlock { param($exePath) (& $exePath --version 2>$null | Out-String).Trim() - } -ArgumentList @($zoxideExecutablePath) -TimeoutSec 5 - $zoxideVersion = if ($zoxideVersionResult) { $zoxideVersionResult } else { 'unknown' } - $initScript = Invoke-WithTimeout -ScriptBlock { + } -ArgumentList @($_pspZoxideExecutablePath) -TimeoutSec 5 + $_pspZoxideVersion = if ($_pspZoxideVersionResult) { $_pspZoxideVersionResult } else { 'unknown' } + $_pspInitScript = Invoke-WithTimeout -ScriptBlock { param($exePath) (& $exePath init --cmd z powershell | Out-String) - } -ArgumentList @($zoxideExecutablePath) -TimeoutSec 10 - if ($initScript) { - $zoxideInitStr = @($initScript) -join "`n" - $zoxideHeader = "# ZOXIDE_CACHE_VERSION: $zoxideVersion" - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($zoxideCachePath, ($zoxideHeader + "`n" + $zoxideInitStr), $utf8NoBom) - try { . $zoxideCachePath } catch { Write-Warning "Failed to initialize zoxide: $_" } + } -ArgumentList @($_pspZoxideExecutablePath) -TimeoutSec 10 + if ($_pspInitScript) { + $_pspZoxideInitStr = @($_pspInitScript) -join "`n" + $_pspZoxideHeader = "# ZOXIDE_CACHE_VERSION: $_pspZoxideVersion" + $_pspUtf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($_pspZoxideCachePath, ($_pspZoxideHeader + "`n" + $_pspZoxideInitStr), $_pspUtf8NoBom) + try { . $_pspZoxideCachePath } catch { Write-Warning "Failed to initialize zoxide: $_" } } else { Write-Warning "Failed to initialize zoxide: $_" @@ -5947,6 +7614,13 @@ if ($isInteractive) { # Query core and plugin commands by category or name substring. function Get-ProfileCommand { + <# + .SYNOPSIS + Query command registry. + .EXAMPLE + Get-ProfileCommand -Category Git + List the git commands. + #> [CmdletBinding()] param( [string]$Category, @@ -5962,6 +7636,10 @@ function Get-ProfileCommand { # Walk through command categories interactively on first run. function Start-ProfileTour { + <# + .SYNOPSIS + Interactive walkthrough. + #> if (-not [Environment]::UserInteractive) { Write-Warning 'Tour requires an interactive session.'; return } # Skip seeded commands that aren't defined on this host (WSL/SSH helpers without their binary). $available = $script:PSP.Commands | Where-Object { Get-Command $_.Name -ErrorAction Ignore } @@ -5969,39 +7647,46 @@ function Start-ProfileTour { 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 '' + Write-PspHost '' + Write-PspHost 'PowerShellPerfect Tour' -ForegroundColor Cyan + Write-PspHost '======================' -ForegroundColor Cyan + Write-PspHost 'Press Enter to see each category, Ctrl+C to quit.' -ForegroundColor DarkGray + Write-PspHost '' $null = Read-Host 'Ready' foreach ($cat in $categories) { - Write-Host '' + Write-PspHost '' $countLabel = if ($cat.Count -eq 1) { 'command' } else { 'commands' } - Write-Host ("-- {0} ({1} {2}) --" -f $cat.Name, $cat.Count, $countLabel) -ForegroundColor Cyan + Write-PspHost ("-- {0} ({1} {2}) --" -f $cat.Name, $cat.Count, $countLabel) -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 + Write-PspHost (" {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 + Write-PspHost (" ...{0} more (Get-ProfileCommand -Category '{1}')" -f ($cat.Group.Count - 8), $cat.Name) -ForegroundColor DarkGray } $null = Read-Host 'Press Enter' } - Write-Host '' - Write-Host 'Extend the profile:' -ForegroundColor Cyan - Write-Host ' profile_user.ps1 - PS overrides (after user-settings.json, before plugins)' - Write-Host ' plugins\*.ps1 - drop files in %LOCALAPPDATA%\PowerShellProfile\plugins' - Write-Host ' user-settings.json - features toggles, commandOverrides, trustedDirs' - Write-Host ' .psprc.ps1 - per-directory profile (opt-in via Add-TrustedDirectory)' - Write-Host '' - Write-Host 'Tour complete.' -ForegroundColor Green + Write-PspHost '' + Write-PspHost 'Extend the profile:' -ForegroundColor Cyan + Write-PspHost ' profile_user.ps1 - PS overrides (after user-settings.json, before plugins)' + Write-PspHost ' plugins\*.ps1 - drop files in %LOCALAPPDATA%\PowerShellProfile\plugins' + Write-PspHost ' user-settings.json - features toggles, commandOverrides, trustedDirs' + Write-PspHost ' .psprc.ps1 - per-directory profile (opt-in via Add-TrustedDirectory)' + Write-PspHost '' + Write-PspHost 'Tour complete.' -ForegroundColor Green } finally { Pop-TabTitle $oldTitle } } -# Trust a directory for persistent .psprc.ps1 loading. +# Trust a directory for .psprc.ps1 loading, approving the file's current content (run it again after the file changes). function Add-TrustedDirectory { + <# + .SYNOPSIS + Trust a dir and approve its current .psprc.ps1. + .EXAMPLE + Add-TrustedDirectory + Approve the .psprc.ps1 in the current directory as it is now. + #> [CmdletBinding(SupportsShouldProcess = $true)] param([string]$Path) if (-not $Path) { $Path = (Get-Location).ProviderPath } @@ -6015,34 +7700,56 @@ function Add-TrustedDirectory { Write-Error "Cannot trust '$resolved': not a directory." return } - if ($script:PSP.TrustedDirs -contains $resolved) { - Write-Host "Already trusted: $resolved" -ForegroundColor DarkGray - return + # Read once: the approved hash and the text loaded below are the same bytes. + $rcContent = Get-PspRcContent -Directory $resolved + $rcHash = if ($rcContent) { $rcContent.Hash } else { '' } + $existing = $script:PSP.TrustedDirs | Where-Object { $_ -ieq $resolved } | Select-Object -First 1 + if ($existing) { + if ($script:PSP.TrustedRcHashes.ContainsKey($existing) -and [string]$script:PSP.TrustedRcHashes[$existing] -eq $rcHash) { + Write-PspHost "Already trusted, and .psprc.ps1 is unchanged since it was approved: $existing" -ForegroundColor DarkGray + return + } + $resolved = $existing } - if (-not $PSCmdlet.ShouldProcess($resolved, 'Trust for .psprc.ps1 auto-load')) { return } - $script:PSP.TrustedDirs.Add($resolved) - # Roll back if persistence fails. + $action = if ($existing) { 'Approve the current .psprc.ps1 content' } else { 'Trust for .psprc.ps1 auto-load' } + if (-not $PSCmdlet.ShouldProcess($resolved, $action)) { return } $saved = $false - try { $saved = Save-TrustedDirectories } + try { $saved = Save-TrustedDirectories -Add $resolved -AddHash $rcHash } 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)" } + if ($rcHash) { + Write-PspHost ("Trusted: {0} (.psprc.ps1 SHA-256 {1})" -f $resolved, $rcHash) -ForegroundColor Green + Write-PspHost 'Any later change to .psprc.ps1 (git pull, checkout) stops it from loading until you run Add-TrustedDirectory again.' -ForegroundColor DarkGray + # The prompt hook only loads on a directory change, so load it now when this is the current directory. + if ($resolved -ieq (Get-Location).ProviderPath) { + Write-PspHost 'Loading .psprc.ps1 now...' -ForegroundColor DarkGray + try { . (New-PspRcScriptBlock -Text $rcContent.Text -Path (Join-Path $resolved '.psprc.ps1')) } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Warning ".psprc.ps1 failed: $($_.Exception.Message)" + } + } + } + else { + Write-PspHost "Trusted: $resolved (no .psprc.ps1 yet; run Add-TrustedDirectory again after creating one)." -ForegroundColor Green } } # Remove a directory from the trust list with case-insensitive support for stale paths. function Remove-TrustedDirectory { + <# + .SYNOPSIS + Remove trusted directory. + .EXAMPLE + Remove-TrustedDirectory C:\src\project + Stop auto-loading that directory's .psprc.ps1. + #> [CmdletBinding(SupportsShouldProcess = $true)] param([string]$Path) if (-not $Path) { $Path = (Get-Location).ProviderPath } @@ -6050,28 +7757,36 @@ function Remove-TrustedDirectory { # 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 + Write-PspHost "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 } + try { $saved = Save-TrustedDirectories -Remove $resolved } 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 + Write-PspHost "Untrusted: $resolved" -ForegroundColor Yellow } # Persist and apply a Windows Terminal background image or clear it. function Set-TerminalBackground { + <# + .SYNOPSIS + Set/clear WT background image (live + persisted). + .EXAMPLE + Set-TerminalBackground "$env:USERPROFILE\Pictures\bg.png" -Opacity 0.1 + Use an image as a faint Windows Terminal background. + .EXAMPLE + Set-TerminalBackground -Clear + Remove the background image. + #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Position = 0)][string]$Path, @@ -6084,6 +7799,7 @@ function Set-TerminalBackground { ) $bgProps = @('backgroundImage', 'backgroundImageOpacity', 'backgroundImageStretchMode', 'backgroundImageAlignment') if (-not $Clear -and -not $Path) { Write-Error 'Usage: Set-TerminalBackground | Set-TerminalBackground -Clear'; return } + $cacheDir = Get-PspCacheDir $resolved = $null if (-not $Clear) { try { $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath } @@ -6096,14 +7812,17 @@ function Set-TerminalBackground { 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) + # The source path hash keeps same-named images from different folders apart. + $srcTag = (Get-CombinedSha256 -Parts @($resolved.ToLowerInvariant())).Substring(0, 8) + $resizedPath = Join-Path $cacheDir ("bg-{0}-{1}-{2}.png" -f $srcName, $srcTag, $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) { + if ($regen -and $PSCmdlet.ShouldProcess($resizedPath, "Write a $ResizeWidth px wide copy of the image")) { + if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } $img = [System.Drawing.Image]::FromFile($resolved) try { $h = [int]($img.Height * ($ResizeWidth / $img.Width)) @@ -6117,7 +7836,7 @@ function Set-TerminalBackground { finally { $g.Dispose(); $bmp.Dispose() } } finally { $img.Dispose() } - Write-Host ("Resized to {0}px wide: {1}" -f $ResizeWidth, $resizedPath) -ForegroundColor DarkGray + Write-PspHost ("Resized to {0}px wide: {1}" -f $ResizeWidth, $resizedPath) -ForegroundColor DarkGray } $resolved = $resizedPath } @@ -6128,19 +7847,7 @@ function Set-TerminalBackground { } } - # Update only supplied appearance settings while initializing missing defaults. - $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.* + # 1. Persist to user-settings.json under defaults.*; only supplied appearance settings change, missing ones get the defaults. $settingsPath = Join-Path $cacheDir 'user-settings.json' try { $settings = Read-UserSettingsForWrite -Path $settingsPath @@ -6150,72 +7857,102 @@ function Set-TerminalBackground { 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 + $userDefaults = Get-PspProp $settings 'defaults' + if ($null -eq $userDefaults) { + $userDefaults = [PSCustomObject]@{} + Set-PspProp $settings 'defaults' $userDefaults } if ($Clear) { - foreach ($p in $bgProps) { - if ($settings.defaults.PSObject.Properties[$p]) { $settings.defaults.PSObject.Properties.Remove($p) } - } + foreach ($p in $bgProps) { Remove-PspProp $userDefaults $p } } else { - & $applyBg $settings.defaults + Set-PspProp $userDefaults 'backgroundImage' $resolved + $requested = [ordered]@{ + backgroundImageOpacity = @('Opacity', $Opacity) + backgroundImageStretchMode = @('StretchMode', $StretchMode) + backgroundImageAlignment = @('Alignment', $Alignment) + } + foreach ($name in $requested.Keys) { + if ($PSBoundParameters.ContainsKey($requested[$name][0]) -or -not (Test-PspProp $userDefaults $name)) { + Set-PspProp $userDefaults $name $requested[$name][1] + } + } } if ($PSCmdlet.ShouldProcess($settingsPath, 'Persist terminal background in user-settings.json')) { - $json = $settings | ConvertTo-Json -Depth 10 - Write-Utf8FileAtomic $settingsPath $json + try { + if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } + Write-Utf8FileAtomic -Path $settingsPath -Content ($settings | ConvertTo-Json -Depth 10) -BackupPath "$settingsPath.bak" + } + catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } + Write-Error "Could not write user-settings.json: $($_.Exception.Message). Background not persisted." + return + } } - # 2. Apply the background to every installed Windows Terminal variant. - if ($settings.PSObject.Properties['windowsTerminal'] -and $settings.windowsTerminal.PSObject.Properties['manage'] -and $settings.windowsTerminal.manage -eq $false) { - Write-Host 'Windows Terminal settings.json is managed elsewhere (windowsTerminal.manage = false). Background persisted to user-settings.json only.' -ForegroundColor DarkGray + # 2. Apply the same values to every installed Windows Terminal variant; the change manifest lets Uninstall-Profile revert them. + if ((Get-PspProp (Get-PspProp $settings 'windowsTerminal') 'manage') -eq $false) { + Write-PspHost 'Windows Terminal settings.json is managed elsewhere (windowsTerminal.manage = false). Background persisted to user-settings.json only.' -ForegroundColor DarkGray return } - $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 + $wtSettingsPaths = @(Get-WindowsTerminalSettingsPaths) + if ($wtSettingsPaths.Count -eq 0) { + Write-PspHost 'Windows Terminal settings.json not found (Store/Preview/Canary/unpackaged). Change persisted; will apply after next Update-Profile.' -ForegroundColor DarkGray return } + $wtArgs = @{ ManifestPath = (Join-Path $cacheDir 'wt-changes.json') } + if ($Clear) { $wtArgs.RemoveDefaults = $bgProps } + else { + $bgValues = [PSCustomObject]@{} + foreach ($p in $bgProps) { if (Test-PspProp $userDefaults $p) { Set-PspProp $bgValues $p (Get-PspProp $userDefaults $p) } } + $wtArgs.Defaults = $bgValues + } foreach ($wtSettingsPath in $wtSettingsPaths) { - try { - $wtRaw = (Get-Utf8FileText $wtSettingsPath) -replace $jsoncCommentPattern, '' - $wt = $wtRaw | ConvertFrom-Json - if (-not $wt.profiles) { $wt | Add-Member -NotePropertyName 'profiles' -NotePropertyValue ([PSCustomObject]@{}) -Force } - if (-not $wt.profiles.defaults) { $wt.profiles | Add-Member -NotePropertyName 'defaults' -NotePropertyValue ([PSCustomObject]@{}) -Force } - if ($Clear) { - foreach ($p in $bgProps) { - if ($wt.profiles.defaults.PSObject.Properties[$p]) { $wt.profiles.defaults.PSObject.Properties.Remove($p) } - } - } - else { - & $applyBg $wt.profiles.defaults - } - if ($PSCmdlet.ShouldProcess($wtSettingsPath, 'Apply terminal background live')) { - $wtJson = $wt | ConvertTo-Json -Depth 100 - Write-Utf8FileAtomic $wtSettingsPath $wtJson - } - } + try { [void](Update-WindowsTerminalSettingsFile -Path $wtSettingsPath @wtArgs) } 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 } + if ($Clear) { Write-PspHost 'Terminal background cleared.' -ForegroundColor Yellow } + else { + Write-PspHost ("Terminal background: {0} (opacity {1}, {2}, {3})" -f $resolved, (Get-PspProp $userDefaults 'backgroundImageOpacity'), + (Get-PspProp $userDefaults 'backgroundImageStretchMode'), (Get-PspProp $userDefaults 'backgroundImageAlignment')) -ForegroundColor Green + } } -# Read user-settings.json or return an empty object when it does not exist. +# Read user-settings.json (comments and trailing commas allowed), or return an empty object when it is missing; throws when it does not parse. function Read-UserSettingsForWrite { param([Parameter(Mandatory)][string]$Path) - if (-not (Test-Path -LiteralPath $Path)) { return [PSCustomObject]@{} } - $raw = Get-Utf8FileText $Path - if ([string]::IsNullOrWhiteSpace($raw)) { return [PSCustomObject]@{} } - return $raw | ConvertFrom-Json -ErrorAction Stop -} - -# Persist trustedDirs and return a success flag for rollback handling. + $parsed = Read-JsonFile $Path + if ($null -eq $parsed) { return [PSCustomObject]@{} } + if ($parsed -isnot [System.Management.Automation.PSCustomObject]) { throw "$Path does not contain a JSON object." } + return $parsed +} + +# Return the trusted directories (as provider paths) and the approved .psprc.ps1 hashes stored in a settings object. +function Get-PspTrustState { + param([AllowNull()][object]$Settings) + $dirs = [System.Collections.Generic.List[string]]::new() + $hashes = @{} + $stored = Get-PspProp $Settings 'trustedRcHashes' + foreach ($entry in (Get-PspArray $Settings 'trustedDirs')) { + if (-not [string]$entry) { continue } + # Entries that no longer resolve (a removed folder or drive) stay as written. + $normalized = try { (Resolve-Path -LiteralPath ([string]$entry) -ErrorAction Stop).ProviderPath } catch { [string]$entry } + if ($dirs -contains $normalized) { continue } + $dirs.Add($normalized) + $hash = Get-PspProp $stored ([string]$entry) + if ($null -ne $hash) { $hashes[$normalized] = [string]$hash } + } + [PSCustomObject]@{ Dirs = $dirs; Hashes = $hashes } +} + +# Apply one trust change to user-settings.json as it is on disk now, so trust changes made in other sessions are kept, +# then reload this session's lists from the result. Returns $false when the file could not be read or written. function Save-TrustedDirectories { - $settingsPath = Join-Path $cacheDir 'user-settings.json' + param([string]$Add, [AllowEmptyString()][string]$AddHash = '', [string]$Remove) + $settingsPath = Join-Path (Get-PspCacheDir) 'user-settings.json' try { $settings = Read-UserSettingsForWrite -Path $settingsPath } @@ -6224,28 +7961,44 @@ function Save-TrustedDirectories { 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 + $state = Get-PspTrustState $settings + if ($Remove) { + foreach ($d in @($state.Dirs | Where-Object { $_ -ieq $Remove })) { [void]$state.Dirs.Remove($d) } + $state.Hashes.Remove($Remove) } - else { - $settings | Add-Member -NotePropertyName 'trustedDirs' -NotePropertyValue $dirs -Force + if ($Add) { + if ($state.Dirs -notcontains $Add) { $state.Dirs.Add($Add) } + $state.Hashes[$Add] = $AddHash + } + $hashes = [ordered]@{} + foreach ($d in $state.Dirs) { + if ($state.Hashes.ContainsKey($d)) { $hashes[$d] = [string]$state.Hashes[$d] } } + Set-PspProp $settings 'trustedDirs' ([object[]]@($state.Dirs)) + Set-PspProp $settings 'trustedRcHashes' ([PSCustomObject]$hashes) try { - $json = $settings | ConvertTo-Json -Depth 10 - Write-Utf8FileAtomic $settingsPath $json - return $true + $dir = Split-Path -Parent $settingsPath + if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + Write-Utf8FileAtomic -Path $settingsPath -Content ($settings | ConvertTo-Json -Depth 10) -BackupPath "$settingsPath.bak" } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } Write-Error "Could not write user-settings.json: $($_.Exception.Message)" return $false } + $script:PSP.TrustedDirs = $state.Dirs + $script:PSP.TrustedRcHashes = $state.Hashes + return $true } # Help Function (PS5-compatible - $PSStyle only exists in PS7.2+) function Show-Help { - if ($null -ne $PSStyle) { + <# + .SYNOPSIS + Show this help message. + #> + # Plain text when NO_COLOR is set (https://no-color.org) or on Windows PowerShell 5.1, which has no $PSStyle. + if ($null -ne $PSStyle -and [string]::IsNullOrEmpty($env:NO_COLOR)) { $c = $PSStyle.Foreground.Cyan; $g = $PSStyle.Foreground.Green $y = $PSStyle.Foreground.Yellow; $m = $PSStyle.Foreground.Magenta; $r = $PSStyle.Reset } @@ -6253,23 +8006,26 @@ function Show-Help { $c = ""; $g = ""; $y = ""; $m = ""; $r = "" } $helpText = @" -${c}PowerShell Profile Help${r} +${c}PowerShell Profile Help${r} (version $($script:PSP.Version)) ${y}=======================${r} +Get-Help shows the details and examples for a command. ${c}Profile & Updates${r} ${g}Edit-Profile${r} / ${g}ep${r} - Open profile in preferred editor. ${g}edit${r} - Open file in preferred editor. -${g}Update-Profile${r} - Sync profile, theme, caches, and WT settings. Use -Force to re-apply. +${g}Update-Profile${r} [-Ref ] - Sync profile, theme, caches, and WT settings (hash-checked). Use -Force to re-apply. + -InstallMissingTools also installs missing tools with winget (accepting the source and package agreements) and the pinned PSFzf. ${g}Update-PowerShell${r} - Check for new PowerShell releases. ${g}Update-Tools${r} - Update winget-managed tools; direct/MSI Oh My Posh installs are preserved. ${g}Invoke-ProfileWizard${r} / ${g}Reconfigure-Profile${r} - Re-run the install wizard (theme, scheme, font, features). ${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}Clear-Cache${r} [-IncludeSystemCaches] - Clear the user temp folder and legacy IE cache (asks first); adds Windows\Temp and Prefetch. ${g}duration${r} - Elapsed time of the last command. ${g}Test-ProfileHealth${r} [-PassThru] / ${g}psp-doctor${r} - Diagnose install: tools, caches, fonts, PATH. -PassThru also returns the rows for scripting. -${g}Uninstall-Profile${r} - Remove profile, caches, and WT changes. Use -All for everything, -HardResetWindowsTerminal to reset WT to defaults, -Force to uninstall PSFzf/tools even under CI/agent env vars. +${g}Uninstall-Profile${r} - Remove profile and caches and revert the WT changes it made (later WT edits are kept). + -RemoveUserData/-RemoveTools/-RemoveFonts or -All for more, -HardResetWindowsTerminal to reset WT to defaults, -Force to uninstall PSFzf/tools even under CI/agent env vars. ${c}Git${r} ${g}gs${r} - git status. ${g}ga${r} - git add . ${g}gcmsg${r} - git commit -m. @@ -6284,7 +8040,7 @@ ${g}ff${r} - Find files recursively. ${g}nf${r} - Create new file ${g}mkcd${r} - Create dir and cd into it. ${g}touch${r} - Create file or update timestamp. ${g}trash${r} - Move to Recycle Bin. -${g}extract${r} - Universal extractor (.zip, .tar, .gz, .7z, .rar). +${g}extract${r} [-Force] - Universal extractor (.zip, .tar, .gz, .7z, .rar); -Force overwrites existing files. ${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. @@ -6294,7 +8050,7 @@ ${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). ${g}head${r} [n] / ${g}tail${r} [n] [-f] - First/last n lines. -${g}sed${r} - Find and replace in file. +${g}sed${r} - Find and replace in file (keeps the encoding, writes .bak). ${g}which${r} - Show command path. ${g}pkill${r} / ${g}pgrep${r} - Kill / list processes by name. ${g}export${r} - Set environment variable. @@ -6306,11 +8062,11 @@ ${g}uptime${r} - System uptime. ${g}sysinfo${r} - Detailed system info. ${g}df${r} - Disk volumes. ${g}flushdns${r} - Clear DNS cache. ${g}ports${r} - Listening TCP ports. ${g}checkport${r} - Test TCP connectivity. ${g}portscan${r} [-Ports n,n,...] - Quick TCP port scan. -${g}tlscert${r} [port] - Check TLS certificate expiry and details. +${g}tlscert${r} [port] - Check TLS certificate expiry, details, and validation errors. ${g}ipinfo${r} [ip] - IP geolocation lookup (no args = your IP). ${g}whois${r} - WHOIS domain lookup (registrar, dates, nameservers). ${g}nslook${r} [type] - DNS lookup (A, MX, TXT, etc.). -${g}env${r} [pattern] - Search/list environment variables. +${g}env${r} [pattern] [-ShowValues] - Search/list environment variables (secret-looking values are masked). ${g}svc${r} [name] [-Count n] [-Live] - htop-like process viewer. ${g}eventlog${r} [n] - Last n event log entries (default 20). ${g}path${r} - Display PATH entries one per line. @@ -6334,11 +8090,11 @@ ${g}vtscan${r} [-Upload] - VirusTotal hash lookup; ${g}-Upload${r} submit ${g}vt${r} - Full VirusTotal CLI (vt-cli). Run ${g}vt --help${r} for details. ${c}Developer${r} -${g}killport${r} - Kill process on a TCP port. +${g}killport${r} - Kill process on a TCP port (refuses protected system processes). ${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. +${g}hb${r} - Upload to a PUBLIC paste (asks first), copy URL. ${g}timer${r} { command } - Measure execution time. ${g}watch${r} { command } [-Interval n] - Repeat command every n seconds (default 2). ${g}bak${r} - Quick timestamped backup. @@ -6346,7 +8102,7 @@ ${g}bak${r} - Quick timestamped backup. ${c}Docker${r} (when installed) ${g}dps${r} / ${g}dpa${r} - Running / all containers. ${g}dimg${r} - Images. ${g}dlogs${r} - Follow logs. ${g}dex${r} [shell] - Exec into container. -${g}dstop${r} - Stop all. ${g}dprune${r} - System prune. +${g}dstop${r} - Stop all running containers. ${g}dprune${r} - System prune. Both ask first. ${c}SSH & Remote${r} (ssh/keygen when installed) ${g}ssh${r} - Wraps ssh.exe with ConnectTimeout=10 + keepalive so hangs respond to Ctrl+C. @@ -6371,21 +8127,22 @@ ${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}Find-FileLocker${r} [-MaxFiles n] - Show which processes hold a file open, or any file inside a folder (Restart Manager API). ${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. +${g}Remove-LockedItem${r} [-Recurse] [-Tree] - Find lockers, confirm, stop 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}nscan${r} [-Mode Quick/Full/Services/Stealth/Vuln/Ports] - Curated nmap profiles; Full/Stealth/Vuln ask first. + Scan only systems you own or are explicitly authorized to test. ${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}pwnd${r} [password] - HIBP k-anonymity breach lookup (only first 5 SHA1 chars leave the host); prompts without echo when no argument is given. +${g}certcheck${r} [port] - Full TLS probe: chain and host-name validation, SAN, SHA256 pin, cipher. ${g}entropy${r} - Shannon entropy (detect packed/encrypted payloads). ${c}Developer+${r} -${g}serve${r} [port] [path] [-Bind addr] - One-line HTTP server (python or npx; loopback only by default). +${g}serve${r} [port] [path] [-Bind addr] - One-line HTTP server (python, or a pinned http-server via npx; loopback only by default). ${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. @@ -6417,7 +8174,7 @@ ${g}Start-ProfileTour${r} - Interactive walkthrough of every category. ${g}Register-ProfileHook${r} -EventName 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. +${g}Add-TrustedDirectory${r} / ${g}Remove-TrustedDirectory${r} [path] - Trust a dir and approve its current .psprc.ps1 (run again after it changes). ${c}Theme${r} ${g}Set-TerminalBackground${r} [-Opacity 0.3] [-StretchMode ...] [-Alignment ...] - Set WT background image (live + persisted). @@ -6426,24 +8183,24 @@ ${g}Set-TerminalBackground${r} -Clear - Remove the background image. Extend the profile without forking: ${m}profile_user.ps1${r} - PS-level overrides (after user-settings.json, before plugins). ${m}%LOCALAPPDATA%\PowerShellProfile\plugins\*.ps1${r} - drop-in plugins (auto-loaded). - ${m}user-settings.json${r} - features toggles, commandOverrides, trustedDirs. + ${m}user-settings.json${r} - features toggles, commandOverrides, trustedDirs (with approved .psprc.ps1 hashes). ${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 + Write-PspHost $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 } + Write-PspHost '' + Write-PspHost ("${c}$($section.Title)${r}") + foreach ($line in $section.Lines) { Write-PspHost $line } } } } # Seed the extensible command registry used by discovery and the profile tour. $script:_seedCommands = @( - @{ Name = 'Update-Profile'; Category = 'Profile'; Synopsis = 'Sync profile, theme, caches, WT settings' } + @{ Name = 'Update-Profile'; Category = 'Profile'; Synopsis = 'Sync profile, theme, caches, WT settings from a branch, tag or commit' } @{ 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' } @@ -6594,118 +8351,107 @@ $script:_seedCommands = @( @{ 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 = 'Add-TrustedDirectory'; Category = 'Extensibility'; Synopsis = 'Trust a dir and approve its current .psprc.ps1' } @{ 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) +foreach ($_pspEntry in $script:_seedCommands) { + $script:PSP.Commands.Add([PSCustomObject]$_pspEntry) } Remove-Variable -Name _seedCommands -Scope Script -ErrorAction SilentlyContinue # Load user settings before PowerShell overrides and plugins. -$userSettingsPath = Join-Path $cacheDir 'user-settings.json' $script:UserSettings = $null -if (Test-Path $userSettingsPath) { - try { - $_rawSettings = Get-Utf8FileText $userSettingsPath - if (-not [string]::IsNullOrWhiteSpace($_rawSettings)) { - $script:UserSettings = $_rawSettings | ConvertFrom-Json -ErrorAction Stop - } - } - catch { Write-Warning "user-settings.json unreadable: $($_.Exception.Message)" } -} +try { $script:UserSettings = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') } +catch { Write-Warning "user-settings.json unreadable: $($_.Exception.Message)" } if ($script:UserSettings) { # Authoritative feature apply (also covers the case where the early PSReadLine-block apply was skipped/failed). Set-PspFeatureOverride $script:UserSettings - if ($script:UserSettings.PSObject.Properties['trustedDirs']) { - foreach ($d in @($script:UserSettings.trustedDirs | Where-Object { $_ })) { - # Normalize trusted directories to provider paths while preserving unresolved entries. - $norm = try { (Resolve-Path -LiteralPath ([string]$d) -ErrorAction Stop).ProviderPath } catch { [string]$d } - [void]$script:PSP.TrustedDirs.Add($norm) - } - } + # Trusted directories carry the SHA-256 of the .psprc.ps1 that was approved; entries without one need a new approval. + $_pspTrust = Get-PspTrustState $script:UserSettings + $script:PSP.TrustedDirs = $_pspTrust.Dirs + $script:PSP.TrustedRcHashes = $_pspTrust.Hashes if ($script:UserSettings.PSObject.Properties['commandOverrides']) { - $_overrideCount = @($script:UserSettings.commandOverrides.PSObject.Properties).Count + $_pspOverrideCount = @($script:UserSettings.commandOverrides.PSObject.Properties).Count if (-not $script:PSP.Features.commandOverrides) { # Warn when commandOverrides entries exist without explicit code-execution opt-in. - 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 + if ($_pspOverrideCount -gt 0 -and $script:PSP.IsInteractive -and $script:PSP.Features.startupMessage) { + Write-PspHost ("commandOverrides ignored ({0} entries): set features.commandOverrides = true in user-settings.json to apply." -f $_pspOverrideCount) -ForegroundColor DarkYellow } } else { - $_appliedOverrides = @() - foreach ($prop in $script:UserSettings.commandOverrides.PSObject.Properties) { - $_ovName = $prop.Name + $_pspAppliedOverrides = @() + foreach ($_pspProp in $script:UserSettings.commandOverrides.PSObject.Properties) { + $_pspOvName = $_pspProp.Name # Ignore underscore-prefixed documentation keys. - if ($_ovName -like '_*') { continue } - $_ovBody = [string]$prop.Value - if ([string]::IsNullOrWhiteSpace($_ovBody)) { continue } + if ($_pspOvName -like '_*') { continue } + $_pspOvBody = [string]$_pspProp.Value + if ([string]::IsNullOrWhiteSpace($_pspOvBody)) { continue } try { - Remove-Item "function:$_ovName" -ErrorAction SilentlyContinue - Remove-Item "alias:$_ovName" -ErrorAction SilentlyContinue - Set-Item "function:$_ovName" -Value ([scriptblock]::Create($_ovBody)) - $_appliedOverrides += $_ovName + Remove-Item "function:$_pspOvName" -ErrorAction SilentlyContinue + Remove-Item "alias:$_pspOvName" -ErrorAction SilentlyContinue + Set-Item "function:$_pspOvName" -Value ([scriptblock]::Create($_pspOvBody)) + $_pspAppliedOverrides += $_pspOvName } - catch { Write-Warning "commandOverride '$_ovName' failed: $($_.Exception.Message)" } + catch { Write-Warning "commandOverride '$_pspOvName' 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 + if ($_pspAppliedOverrides.Count -gt 0 -and $script:PSP.IsInteractive -and $script:PSP.Features.startupMessage) { + Write-PspHost ("commandOverrides active ({0}): {1}" -f $_pspAppliedOverrides.Count, ($_pspAppliedOverrides -join ', ')) -ForegroundColor Yellow } } } } # Load persistent PowerShell overrides after JSON settings and before plugins. -$userProfile = Join-Path (Split-Path $PROFILE) "profile_user.ps1" -if (Test-Path $userProfile) { - try { . $userProfile } +$_pspUserProfile = Join-Path (Split-Path $PROFILE) "profile_user.ps1" +if (Test-Path -LiteralPath $_pspUserProfile) { + try { . $_pspUserProfile } catch { Write-Warning "Failed to load profile_user.ps1: $_" } } # Dot-source plugins with script scope while isolating errors per file. -$pluginDir = Join-Path $cacheDir 'plugins' -if (-not (Test-Path $pluginDir)) { - try { New-Item -ItemType Directory -Path $pluginDir -Force | Out-Null } catch { $null = $_ } +$_pspPluginDir = Join-Path $cacheDir 'plugins' +if (-not (Test-Path -LiteralPath $_pspPluginDir)) { + try { New-Item -ItemType Directory -Path $_pspPluginDir -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 } +if (Test-Path -LiteralPath $_pspPluginDir) { + foreach ($_pspPlugin in (Get-ChildItem -LiteralPath $_pspPluginDir -Filter *.ps1 -File -ErrorAction SilentlyContinue | Sort-Object Name)) { + try { . $_pspPlugin.FullName } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } - Write-Warning "Plugin '$($plugin.Name)' failed to load: $($_.Exception.Message)" + Write-Warning "Plugin '$($_pspPlugin.Name)' failed to load: $($_.Exception.Message)" } } } # Check GitHub weekly for updates when the interactive updateCheck feature is enabled. -if ($isInteractive -and $script:PSP.Features.updateCheck) { +if ($script:PSP.IsInteractive -and $script:PSP.Features.updateCheck) { try { - $_ucStampFile = Join-Path $cacheDir 'last-update-check.txt' - $_ucDoCheck = $true - if (Test-Path $_ucStampFile) { - $_ucRaw = (Get-Content $_ucStampFile -Raw -ErrorAction SilentlyContinue).Trim() - $_ucLast = [datetime]::MinValue - if ([datetime]::TryParse($_ucRaw, [ref]$_ucLast)) { - if (((Get-Date) - $_ucLast).TotalDays -lt 7) { $_ucDoCheck = $false } - } - } - if ($_ucDoCheck) { - $_ucLatest = Get-LatestMainCommitSha - if ($_ucLatest) { - $_ucBaselineFile = Join-Path $cacheDir 'applied-commit.sha' - $_ucStored = if (Test-Path $_ucBaselineFile) { (Get-Content $_ucBaselineFile -Raw -ErrorAction SilentlyContinue).Trim() } else { $null } - if (-not $_ucStored) { + $_pspUcStampFile = Join-Path $cacheDir 'last-update-check.txt' + $_pspUcDoCheck = $true + if (Test-Path -LiteralPath $_pspUcStampFile) { + $_pspUcRaw = (Get-Utf8FileText $_pspUcStampFile).Trim() + $_pspUcLast = [datetime]::MinValue + if ([datetime]::TryParse($_pspUcRaw, [ref]$_pspUcLast)) { + if (((Get-Date) - $_pspUcLast).TotalDays -lt 7) { $_pspUcDoCheck = $false } + } + } + if ($_pspUcDoCheck) { + $_pspUcLatest = Get-LatestMainCommitSha + if ($_pspUcLatest) { + $_pspUcBaselineFile = Join-Path $cacheDir 'applied-commit.sha' + $_pspUcStored = if (Test-Path -LiteralPath $_pspUcBaselineFile) { (Get-Utf8FileText $_pspUcBaselineFile).Trim() } else { $null } + if (-not $_pspUcStored) { # 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 + Write-Utf8FileAtomic -Path $_pspUcBaselineFile -Content $_pspUcLatest + Write-PspHost ("Update-check enabled. Baseline: {0}. You'll see a notification here when main moves past this commit." -f $_pspUcLatest.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 + elseif ($_pspUcStored -ne $_pspUcLatest) { + Write-PspHost ("Update available: main is at {0}... (applied {1}...). Run: Update-Profile" -f $_pspUcLatest.Substring(0, 7), $_pspUcStored.Substring(0, 7)) -ForegroundColor Yellow } # Record the check time only after a successful API response. - [System.IO.File]::WriteAllText($_ucStampFile, (Get-Date).ToString('o'), [System.Text.UTF8Encoding]::new($false)) + Write-Utf8FileAtomic -Path $_pspUcStampFile -Content (Get-Date).ToString('o') } } } @@ -6716,8 +8462,14 @@ if ($isInteractive -and $script:PSP.Features.updateCheck) { Invoke-ProfileHook -EventName 'OnProfileLoad' # Startup complete - show load time -$profileStopwatch.Stop() -if ($isInteractive -and $script:PSP.Features.startupMessage) { - Write-Host "Profile loaded in $($profileStopwatch.ElapsedMilliseconds)ms." -ForegroundColor DarkGray - Write-Host "Use 'Show-Help' or 'Start-ProfileTour' to explore." -ForegroundColor Yellow +$_pspStopwatch.Stop() +$script:PSP.LoadTimeMs = $_pspStopwatch.ElapsedMilliseconds +if ($script:PSP.IsInteractive -and $script:PSP.Features.startupMessage) { + Write-PspHost "Profile loaded in $($_pspStopwatch.ElapsedMilliseconds)ms." -ForegroundColor DarkGray + Write-PspHost "Use 'Show-Help' or 'Start-ProfileTour' to explore." -ForegroundColor Yellow +} +# Load-time temporaries all start with _psp, so variables that profile_user.ps1 or plugins define are left alone. +foreach ($_pspName in @(Get-Variable -Name '_psp*' -Scope Script -ErrorAction Ignore | ForEach-Object { $_.Name })) { + Remove-Variable -Name $_pspName -Scope Script -ErrorAction Ignore } +Remove-Variable -Name '_pspName' -Scope Script -ErrorAction Ignore diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..cc63c11 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,19 @@ +# PSScriptAnalyzer settings shared by CI (.github/workflows/ci.yml) and tests/lint.ps1. +# tests/ is not analyzed: the harnesses call profile aliases by their short names and hold fixtures that trip the rules. +@{ + Severity = @('Error', 'Warning') + ExcludeRules = @( + # Console output is the interface of an interactive profile; Write-PspHost wraps it for NO_COLOR. + 'PSAvoidUsingWriteHost' + # uptime keeps a Get-WmiObject fallback for Windows PowerShell 5.1. + 'PSAvoidUsingWMICmdlet' + # Short Unix-style helpers (touch, mkcd, export) change state by design; destructive commands support -WhatIf. + 'PSUseShouldProcessForStateChangingFunctions' + # Files are ASCII-only, which CI checks separately; a BOM is not needed. + 'PSUseBOMForUnicodeEncodedFile' + # Argument-completer and event scriptblocks must accept parameters they do not use. + 'PSReviewUnusedParameter' + # Plural nouns keep established names such as Get-WindowsTerminalSettingsPaths and Update-Tools. + 'PSUseSingularNouns' + ) +} diff --git a/README.md b/README.md index faf911e..8f66967 100644 --- a/README.md +++ b/README.md @@ -3,93 +3,115 @@ [![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-actively%20maintained-brightgreen)](https://github.com/26zl/PowerShellPerfect/commits/main) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -> **Stability.** The command surface, install flow, and extension points are settled, and new work stays additive where it can. Every push and pull request runs PSScriptAnalyzer, a Windows PowerShell 5.1 + PowerShell 7 parse and load check, a sandboxed install/uninstall cycle, and a functional command suite. Pin `-ExpectedSha256` when you want byte-identical installs. Bug reports and PRs are welcome (see [CONTRIBUTING.md](CONTRIBUTING.md)). -> 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 145+ commands (Unix-style utilities plus git, network, security, sysadmin, WSL, and docker), 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 145+ commands (Unix-style utilities plus git, network, security, sysadmin, WSL, and docker), a tuned Oh My Posh prompt, fuzzy search, zoxide, and a full uninstall + self-update behind it. + +> **Stability.** The command surface, install flow, and extension points are settled; new work stays additive where it can and is listed in [CHANGELOG.md](CHANGELOG.md). Every push and pull request runs PSScriptAnalyzer, Windows PowerShell 5.1 and PowerShell 7 parse and load checks with a load-time budget, regression tests on both editions, a sandboxed install/uninstall cycle, and a functional command suite. Releases are tagged `vX.Y.Z` and published with `SHA256SUMS.txt` and a build provenance attestation. + +## Install + +Run in a **normal (non-elevated)** PowerShell window; setup needs no administrator rights. Replace `v1.0.0` with the [latest release](https://github.com/26zl/PowerShellPerfect/releases/latest): ```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)) +$v = 'v1.0.0' +$base = "https://github.com/26zl/PowerShellPerfect/releases/download/$v" +$dir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "psp-$v-$(Get-Random)") +foreach ($f in 'setup.ps1', 'SHA256SUMS.txt') { Invoke-WebRequest "$base/$f" -OutFile (Join-Path $dir $f) -UseBasicParsing } +# Check setup.ps1 against the release's checksum before running it. +$bytes = [IO.File]::ReadAllBytes((Join-Path $dir 'setup.ps1')) +$hash = -join ([Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) +$line = Get-Content (Join-Path $dir 'SHA256SUMS.txt') | Where-Object { $_ -match '\ssetup\.ps1$' } +if (-not $line -or $line.Split(' ')[0] -ne $hash) { throw 'setup.ps1 does not match SHA256SUMS.txt; do not run it.' } +& ([scriptblock]::Create([Text.Encoding]::UTF8.GetString($bytes))) -Ref $v +``` -# Then apply, pinned to the hash the step above printed (reproducible, integrity-checked): -& ([scriptblock]::Create($setup)) -ExpectedSha256 '' +With `-Ref vX.Y.Z`, setup resolves the tag to its commit, downloads the profile, `theme.json` and `terminal-config.json` from that commit, and checks them against the same release's `SHA256SUMS.txt` **before changing anything**. The checksum file and the downloads come from GitHub, so they protect against a changed `main` branch and broken downloads; to check that a release was really built by this repository's workflow, use the [GitHub CLI](https://cli.github.com/) instead: + +```powershell +gh release download v1.0.0 --repo 26zl/PowerShellPerfect --dir psp-release +gh attestation verify psp-release\setup.ps1 --repo 26zl/PowerShellPerfect # repeat for the other files +cd psp-release; powershell -ExecutionPolicy Bypass -File .\setup.ps1 # installs from the verified files ``` -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`: +**Latest `main` instead of a release.** Without a release tag, setup prints the hashes of what it downloaded and stops. Check the commit it names, then re-run pinned to the printed hash (or accept trust-on-first-use with `-SkipHashCheck`): ```powershell -& ([scriptblock]::Create($setup)) -SkipHashCheck +$setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" +& ([scriptblock]::Create($setup)) # prints the bundle hash and the commit, then stops +& ([scriptblock]::Create($setup)) -Ref -ExpectedSha256 '' # installs exactly that commit ``` -> **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). +Run setup as a script block, as shown, or as a file; `irm ... | iex` is not supported. As a script block it does not close your window when it exits and removes the variables it created in your session. + +The printed hash is computed over the bytes you just downloaded: it makes the install reproducible and detects changes in transit, but it cannot tell you whether those bytes are what the maintainer intended. That is what release checksums and attestations are for. 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). +The **install wizard runs by default** in an interactive window (pass `-SkipWizard` for repo defaults). Every run is logged to `%LOCALAPPDATA%\PowerShellProfile\logs\setup-.log`; `-WhatIf` shows the changes without making them. 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) +- [Requirements](#requirements) · [What setup changes](#what-setup-changes) · [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) +- [Commands](#commands) · [Compared to alternatives](#compared-to-alternatives) · [Limitations](#limitations) · [Troubleshooting](#troubleshooting) · [Tests](#tests) · [Roadmap](#roadmap) ## Requirements | | | | --- | --- | -| **OS** | Windows 10 / 11 (Windows Terminal recommended) | +| **OS** | Windows 10 1809+ / Windows 11 (Windows Terminal recommended). CI runs on Windows Server images; client versions are checked by hand. | | **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. | +| **Privileges** | None. Without elevation the Nerd Font is installed for your user and the optional telemetry opt-out is set for your user; in an elevated shell both are machine-wide. Run setup unelevated so the terminal it restarts into is not elevated. | +| **Network** | Install and `Update-Profile` fetch the profile bundle from GitHub, the Oh My Posh theme and Nerd Font at pinned versions, and CLI tools through 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 +## What setup changes -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. Non-interactive environments skip it. +| Change | Where | Reverted by | +| --- | --- | --- | +| Profile for both editions, previous profile kept as `profile.original.ps1` (once; a copy of this profile is never kept as the original) and `oldprofile..ps1` (latest 5) | `Documents\PowerShell`, `Documents\WindowsPowerShell` | `Uninstall-Profile` (restores `profile.original.ps1`; `-RemoveUserData` also removes the `oldprofile` copies) | +| Caches, `user-settings.json`, install record (`installed.json`), Windows Terminal change record (`wt-changes.json`), logs | `%LOCALAPPDATA%\PowerShellProfile` | `Uninstall-Profile` (`user-settings.json` and `plugins\` stay unless `-RemoveUserData`) | +| Windows Terminal: font, opacity, color scheme, tab theme, `Ctrl+A` binding, and `-NoLogo` on the PowerShell profiles' command lines. Every installed variant (Stable, Preview, Canary, unpackaged); each value is recorded with what it replaced, and the untouched pre-install file is kept as `settings.json.psp-original` | `settings.json` of each variant | `Uninstall-Profile` reverts each recorded value it still finds; values you changed later are kept | +| winget packages: Oh My Posh, eza, zoxide, fzf, bat, ripgrep (winget accepts the source and package agreements for you) | winget | `Uninstall-Profile -RemoveTools` | +| PSFzf module, pinned version, current user | PowerShell Gallery | `Uninstall-Profile -RemoveTools` | +| Nerd Font, pinned release, archive SHA-256 checked | per-user font store (machine-wide when elevated) | `Uninstall-Profile -RemoveFonts` | +| Optional, asked first: `POWERSHELL_TELEMETRY_OPTOUT=true` | user environment (machine when elevated) | `Uninstall-Profile` (only if setup set it) | +| Optional, asked first: CurrentUser execution policy `AllSigned` -> `RemoteSigned`; uninstalling the Terminal-Icons module (this profile shows icons through eza instead) | | not reverted (you chose it) | -```powershell -# Recommended (review first): run with no hash flag. setup.ps1 downloads the bundle, -# prints its SHA-256 hashes, and stops WITHOUT installing. Review them (ideally against -# the commit list at https://github.com/26zl/PowerShellPerfect/commits/main), then re-run -# pinning the combined hash for a verified install (the wizard runs during install): -$setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" -& ([scriptblock]::Create($setup)) # prints hashes, then stops -& ([scriptblock]::Create($setup)) -ExpectedSha256 '' # verified install +## Install Wizard -# Unverified fallback: skip the integrity check and install whatever was just downloaded. -& ([scriptblock]::Create($setup)) -SkipHashCheck +Inspired by [powerlevel10k](https://github.com/romkatv/powerlevel10k)'s `p10k configure`. **Runs automatically on every interactive install**; non-interactive environments skip it. +```powershell # 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): +# Resume a half-finished wizard (state in %LOCALAPPDATA%\PowerShellProfile\wizard-state.json): .\setup.ps1 -Resume -# Re-run the wizard any time after install (downloads latest setup.ps1 + elevates): -Reconfigure-Profile +# Re-run the wizard any time after install (setup.ps1 of a release is checked against its SHA256SUMS.txt; runs in this window): +Reconfigure-Profile -Ref v1.0.0 +# From a branch or commit, pin setup.ps1 to the hash the first run prints: +Reconfigure-Profile -Ref -ExpectedSha256 '' ``` -> Same guarantee as the Quick Install above: the printed hash proves download integrity, not upstream authenticity (see the note there). A signed-release or tag-pinning model would add authenticity and is left as a maintainer follow-up. - **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** — derive from chosen scheme (default) / shipped palette / 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. +1. **Quick start**: one-key preset: Tokyo Night scheme, CascadiaCode Nerd Font, the first editor of the list below that is already installed (nothing is installed for it), dark chrome, default features. Answer yes to jump straight to the summary. +2. **Oh My Posh theme**: the theme catalog of the pinned Oh My Posh release ([v31.8.0](https://github.com/JanDeDobbeleer/oh-my-posh/tree/v31.8.0/themes)). Pick by number or partial name. The default and preset themes are checked against pinned SHA-256 hashes. 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 from [Nerd Fonts v3.5.1](https://github.com/ryanoasis/nerd-fonts/releases/tag/v3.5.1); each archive is checked against the hash from that release's `SHA-256.txt`. +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**: derive from chosen scheme (default) / shipped palette / 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. +- All choices persist to `user-settings.json` so `Update-Profile` re-applies them; nothing hardcoded into the profile. A `user-settings.json` that does not parse is never overwritten (comments and trailing commas are fine); the previous file is kept as `user-settings.json.bak`. - Summary screen at the end with "apply all?" confirmation. -- State file enables `-Resume` if the wizard is interrupted or cancelled. +- The state file enables `-Resume`; resumed values are re-checked against the curated lists before they are used. - All 145+ commands and the extensibility system ship regardless of wizard choices; the wizard only selects cosmetics and opt-ins. ## At a glance @@ -97,33 +119,32 @@ Reconfigure-Profile | | | | --- | --- | | **145+ commands** | git, files, unix tools, network, security, developer, sysadmin, WSL, docker, ssh, clipboard | -| **Install wizard (default)** | Runs automatically when setup is invoked. Picks OMP theme, WT color scheme (7 curated), Nerd Font (6 curated), tab-bar + window chrome, terminal appearance, PSReadLine colors, background, editor, telemetry opt-out, feature toggles. `-Resume` on interrupt. See [Install Wizard](#install-wizard) for details. | +| **Install wizard (default)** | 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. See [Install Wizard](#install-wizard). | | **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 | +| **Verified updates** | `Update-Profile -Ref vX.Y.Z` checks downloads against the release's `SHA256SUMS.txt`; branches and commits need a pinned hash. `profile_user.ps1` and `user-settings.json` survive updates. | +| **Reversible** | Windows Terminal changes are recorded value by value and reverted on uninstall without discarding your later edits; see [What setup changes](#what-setup-changes). | | **PS5 + PS7** | Installs to both profile directories; every PS5/PS7 API fork is guarded | | **Sandbox-safe** | Non-interactive environments skip network calls and UI setup 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 | +| **Hardened** | Passwords/tokens filtered from PSReadLine history; `env` masks secret-looking values; per-directory `.psprc.ps1` files run only while their content matches what you approved | +| **Tested** | Regression tests on PS 5.1 and 7, sandboxed install/uninstall cycles, and a functional suite in which every exported command is either executed or skipped with a recorded reason (at least 60% must execute) | Inspired by [ChrisTitusTech/powershell-profile](https://github.com/ChrisTitusTech/powershell-profile); design cues from [powerlevel10k](https://github.com/romkatv/powerlevel10k) and [starship](https://github.com/starship/starship). ## Install (alternatives) -The one-liner at the top is the recommended path. These are alternatives when you need a local clone or want to tweak defaults without going through the wizard. - -> **Recommended for Oh My Posh:** Install the x64 MSI from the [releases](https://github.com/JanDeDobbeleer/oh-my-posh/releases) page (see [Oh My Posh](https://github.com/JanDeDobbeleer/oh-my-posh)) instead of `winget`/Store — this profile preserves a direct install and avoids the WindowsApps path. If you already have the MSI install, setup leaves it as is. +> **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 # wizard runs by default +.\setup.ps1 # wizard runs by default .\setup.ps1 -SkipWizard # apply repo defaults instead +.\setup.ps1 -WhatIf # show what would change ``` -`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. +`setup.ps1` uses the folder it runs from when that folder holds the profile, `theme.json`, `terminal-config.json` and `setprofile.ps1` (a clone or an extracted release), so nothing is downloaded and nothing is hash-checked; it prints the bundle hash of the files it installs, and `-LocalRepo . -ExpectedSha256 ''` checks the local files first. A git clone has CRLF line endings in `.ps1` files, so a release hash (which covers the published LF files) only matches an extracted release. Passing `-Ref` or `-ExpectedSha256` without `-LocalRepo` turns detection off, so a folder that merely contains files with those names cannot replace the verified download. A separate `.\setprofile.ps1` copies only the profile (`-WhatIf` supported). When running locally you can override terminal defaults: @@ -131,6 +152,8 @@ When running locally you can override terminal defaults: .\setup.ps1 -SkipWizard -Opacity 85 -ColorScheme "One Half Dark" -FontSize 12 ``` +Exit codes: `0` when the profile is installed (optional tools that were skipped or failed are listed in the summary), `1` when nothing was applied (verification failed, unsupported system) or the profile could not be installed. `Get-Help .\setup.ps1 -Full` lists every parameter. + > **Controlled Folder Access:** setup writes the profile to `Documents`, which CFA protects. The > symptom is a misleading `Could not find file` and a Defender event 1123. Allow the PowerShell > you are running **for the install only**, then take it out again. A permanent entry lets every @@ -149,19 +172,19 @@ When running locally you can override terminal defaults: ## Updates ```powershell -Update-Profile # Sync profile, theme, caches, and Windows Terminal settings -Update-PowerShell # Check for new PowerShell 7 releases -Update-Tools # Update winget-managed tools; direct/MSI Oh My Posh installs are preserved +Update-Profile -Ref v1.1.0 # Install a release, checked against its SHA256SUMS.txt +Update-Profile # Latest main: prints the hashes and stops; re-run with -Ref -ExpectedSha256 '' +Update-Profile -InstallMissingTools # Also install missing managed tools with winget (accepting the agreements) and the pinned PSFzf +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 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. +`Update-Profile` resolves `-Ref` to a commit, so the profile, `theme.json` and `terminal-config.json` always come from the same commit. For a release tag it checks the downloads against the release's `SHA256SUMS.txt`; for a branch or commit it needs `-ExpectedSha256` (the hash printed by the previous run) or `-SkipHashCheck`. Nothing is installed unless you pass `-InstallMissingTools` (which also works when the profile is already up to date); without it, missing tools are only listed. A `user-settings.json` that does not parse leaves Windows Terminal's `settings.json` alone until it is fixed. `-Force` re-applies settings even when nothing changed. `psp-doctor` shows the installed version, ref and commit. ## Uninstall -Remove the profile, caches, and Windows Terminal changes: - ```powershell -Uninstall-Profile # Core cleanup: profile files, caches, WT restore +Uninstall-Profile # Profile files, caches, and the Windows Terminal changes the profile made Uninstall-Profile -RemoveUserData -RemoveFonts # Everything except the tools (CLI tools and PSFzf stay) Uninstall-Profile -RemoveTools # Also uninstall managed CLI tools and the PSFzf module (including direct/MSI Oh My Posh when detected) Uninstall-Profile -All # Remove everything including tools, fonts, and user data @@ -169,24 +192,26 @@ Uninstall-Profile -All -HardResetWindowsTerminal # Same as -All, but also delete Uninstall-Profile -All -Force # Also uninstall PSFzf/managed tools when CI or agent guards are active ``` -Optional switches: `-RemoveTools` (winget-managed tools plus the PSFzf module, and direct/MSI Oh My Posh when registered as MSI), `-RemoveUserData` (`profile_user.ps1`, `user-settings.json`, and your `plugins/`), `-RemoveFonts` (Nerd Fonts from both the per-user and machine-wide font stores; the machine-wide store needs 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. +Windows Terminal is reverted change by change in every installed variant: each value the profile set goes back to what it was before install, unless you have changed it since, in which case your value stays. Installs made by older versions without a change record restore the oldest backup (closest to the pre-install file) and keep the current file next to it as `settings.json.pre-uninstall-.json`. With `windowsTerminal.manage = false`, a `user-settings.json` that does not parse, or a symlinked `settings.json`, uninstall leaves the file alone. `-HardResetWindowsTerminal` deletes `settings.json` but keeps the backups unless `-RemoveUserData` is also given. + +A plain `Uninstall-Profile` keeps `user-settings.json`, `profile_user.ps1`, `plugins/`, the `oldprofile.*.ps1` copies and `settings.json.psp-original` (Windows Terminal settings from before the first install), and lists them under **Preserved**; `-RemoveUserData` (or `-All`) removes them. Machine-wide fonts and a machine-wide telemetry opt-out need an elevated shell to remove; uninstall says so and lists them. All switches support `-WhatIf`. ## Customization Four extension points survive updates. From simplest to most powerful: -- **`user-settings.json`** (`%LOCALAPPDATA%\PowerShellProfile\`) - JSON overrides. Keys: +- **`user-settings.json`** (`%LOCALAPPDATA%\PowerShellProfile\`) - JSON overrides (comments and trailing commas allowed). Keys: - `theme`, `windowsTerminal`, `defaults`, `keybindings` - terminal and OMP theme - `windowsTerminal.manage` - set to `false` when another tool owns Windows Terminal's `settings.json` (a dotfiles repo, for example). `setup.ps1`, `Update-Profile`, `Set-TerminalBackground` and `Uninstall-Profile` then leave that file alone, and `psp-doctor` reports who owns it. A symlinked `settings.json` is always written in place, so the link survives updates. - `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`) + - `trustedDirs` and `trustedRcHashes` - directories whose `.psprc.ps1` auto-loads and the SHA-256 of the file you approved (managed by `Add-TrustedDirectory`) - **`profile_user.ps1`** (`Split-Path $PROFILE`) - PowerShell overrides (aliases, functions, editor, colors, modules), dot-sourced after `user-settings.json` (so PS-level definitions win over JSON `commandOverrides` and feature toggles) and before `plugins/` - **`plugins/*.ps1`** (`%LOCALAPPDATA%\PowerShellProfile\plugins\`) - drop-in plugins. Each file is auto-loaded; errors are isolated per plugin -- **`.psprc.ps1`** (per directory) - project-specific profile. Auto-loads on `cd` into a directory registered with `Add-TrustedDirectory`. Use `function global:foo` / `Set-Alias -Scope Global` for lasting definitions; `$env:VAR` always persists +- **`.psprc.ps1`** (per directory) - project-specific profile. `Add-TrustedDirectory` approves the file as it is now; it then auto-loads on `cd` into that directory **only while its content is unchanged**. After any change (for example a `git pull` or branch switch) it is skipped with a warning until you review it and run `Add-TrustedDirectory` again, like `direnv allow`. The file is read once and the checked text is what runs (`$PSScriptRoot` is its folder). 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. +Set `NO_COLOR` (any value, see [no-color.org](https://no-color.org)) to turn off colored output from the profile's commands and `Show-Help`. Call `Start-ProfileTour` for a live walkthrough, `Get-ProfileCommand -Category ` to list what's available, or `Get-Help ` for a command's details. ### Background Image @@ -195,7 +220,7 @@ 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. +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` and applied live to every Windows Terminal variant through the same change record, so `Uninstall-Profile` reverts it. ## Keyboard Shortcuts @@ -223,13 +248,13 @@ Run `Show-Help` in your terminal for a colored version of this list. | --- | --- | | `edit ` | Open file in preferred editor | | `Edit-Profile` / `ep` | Open profile in preferred editor | -| `Update-Profile` | Sync profile, theme, caches, and WT settings | +| `Update-Profile [-Ref ]` | Sync profile, theme, caches, and WT settings (hash-checked); `-InstallMissingTools` also installs missing tools | | `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) | +| `Uninstall-Profile` | Remove profile, caches, and the WT changes it made (`-All` for everything) | ### Git @@ -254,8 +279,8 @@ Run `Show-Help` in your terminal for a colored version of this list. | `nf ` | Create new file | | `mkcd ` | Create directory and cd into it | | `touch ` | Create file or update timestamp | -| `trash ` | Move to Recycle Bin | -| `extract ` | Universal extractor (.zip, .tar, .gz, .7z, .rar) | +| `trash ` | Move to Recycle Bin (reports when it was cancelled or the drive has no Recycle Bin); refuses in SSH, WinRM and service sessions, where Windows would delete permanently | +| `extract [-Force]` | Universal extractor (.zip, .tar, .gz, .7z, .rar); refuses to overwrite existing files without `-Force` | | `file ` | Identify file type via magic bytes | | `sizeof ` | Human-readable file/directory size | | `docs` / `dtop` | Jump to Documents / Desktop | @@ -269,9 +294,9 @@ Run `Show-Help` in your terminal for a colored version of this list. | `grep [dir]` | Search for pattern in files (ripgrep) | | `head [n]` | First n lines of file | | `tail [n] [-f]` | Last n lines of file | -| `sed ` | Find and replace in file | +| `sed ` | Find and replace in file; keeps the file's encoding and writes `.bak` | | `which ` | Show command path | -| `pkill ` | Kill processes by name | +| `pkill ` | Kill processes by name (asks before stopping wildcard matches; refuses match-everything patterns and core Windows processes) | | `pgrep ` | List processes by name | | `export ` | Set environment variable | @@ -289,11 +314,11 @@ Run `Show-Help` in your terminal for a colored version of this list. | `ports` | Listening TCP ports | | `checkport ` | Test TCP connectivity | | `portscan [-Ports]` | Quick TCP port scan (15 common ports) | -| `tlscert [port]` | Check TLS certificate expiry and details | +| `tlscert [port]` | Check TLS certificate expiry, details, and validation errors | | `ipinfo [ip]` | IP geolocation lookup over HTTPS (no args = your IP) | | `whois ` | WHOIS domain lookup (registrar, dates, nameservers) | | `nslook [type]` | DNS lookup (A, MX, TXT, etc.) | -| `env [pattern]` | Search/list environment variables | +| `env [pattern] [-ShowValues]` | Search/list environment variables; secret-looking values are masked unless `-ShowValues` | | `svc [name] [-Count n] [-Live]` | htop-like process viewer | | `eventlog [n]` | Last n event log entries (default 20) | | `path` | Display PATH entries one per line | @@ -301,11 +326,11 @@ Run `Show-Help` in your terminal for a colored version of this list. | `speedtest` | Download speed test | | `wifipass [ssid] [-Reveal]` | List saved WiFi profiles (passwords masked unless `-Reveal`) | | `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 | +| `Clear-Cache` [-IncludeSystemCaches] | Clear the user temp folder and the legacy Internet Explorer (WinINet) cache after confirmation; refuses a `TEMP` that is not a `Temp`/`Tmp` folder (or a Remote Desktop session folder such as `Temp\2`) and keeps going past files in use. `-IncludeSystemCaches` adds `Windows\Temp` and `Prefetch` (admin) | +| `Clear-ProfileCache` | Reset profile caches plus OMP internal caches (settings and state files are kept) | | `duration` | Show elapsed time of the last executed command | -| `Test-ProfileHealth [-PassThru]` / `psp-doctor` | Diagnose install (tools, caches, fonts, PATH, modules); `-PassThru` also emits the result rows for scripting | -| `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. | +| `Test-ProfileHealth [-PassThru]` / `psp-doctor` | Diagnose install (version, tools, caches, fonts, PATH, modules); `-PassThru` also emits the result rows for scripting | +| `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) from an elevated shell (`admin`): unelevated, WinUtil relaunches itself from its latest release, which the hash check never saw. The script is hashed and run from memory, 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 @@ -317,9 +342,9 @@ Run `Show-Help` in your terminal for a colored version of this list. | `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 | +| `Find-FileLocker [-MaxFiles n]` | Show processes holding a file open, or any file inside a folder (Windows Restart Manager API) | +| `Stop-StuckProcess ` | Escalating kill: `Stop-Process -Force` -> `taskkill /F` -> `/F /T` for processes that ignore normal kill | +| `Remove-LockedItem [-Recurse] [-Tree]` | Find lockers, confirm each process, stop them, then delete. For "file is in use" errors | ### Security & Crypto @@ -333,30 +358,30 @@ 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 [-Upload]` | VirusTotal hash lookup; `-Upload` submits unknown files | +| `vtscan [-Upload]` | VirusTotal hash lookup; `-Upload` submits unknown files, which VirusTotal shares with its community and partners | | `vt ` | Full VirusTotal CLI (vt-cli) | -| `nscan [-Mode ...]` | Nmap wrapper with curated scan profiles (Quick/Full/Services/Stealth/Vuln/Ports) | +| `nscan [-Mode ...]` | Nmap wrapper with curated scan profiles (Quick/Full/Services/Stealth/Vuln/Ports); Full, Stealth and Vuln ask first. Scan only systems you own or are authorized to test | | `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 | +| `pwnd [password]` | HIBP k-anonymity breach lookup (only first 5 SHA1 chars leave the host); prompts without echo when no argument is given | +| `certcheck [port]` | Full TLS probe: chain and host-name validation, SAN, SHA256 pin, cipher | | `entropy ` | Shannon entropy (detect packed/encrypted payloads) | ### Developer | Command | Description | | --- | --- | -| `killport ` | Kill process on a TCP port | +| `killport ` | Kill process on a TCP port (refuses core Windows processes) | | `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 (confirms first; `-Force`/`-Confirm:$false` to skip) | +| `hb ` | Upload to a PUBLIC paste (bin.christitus.com) and copy the URL; asks first (`-Force`/`-Confirm:$false` to skip) | | `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] [-Bind addr]` | One-line HTTP server (python or npx). Loopback only by default; `-Bind 0.0.0.0` shares it on the LAN | -| `gitignore ` | Generate .gitignore from gitignore.io | +| `serve [port] [path] [-Bind addr]` | One-line HTTP server (python, or a pinned `http-server` via npx). Loopback only by default; `-Bind 0.0.0.0` shares it on the LAN | +| `gitignore ` | Generate .gitignore from gitignore.io (Toptal) | | `gcof` | Fuzzy git branch checkout (fzf) | | `envload [path]` | Load .env file into current session | | `tldr ` | Quick command-example lookup (tldr-pages) | @@ -387,7 +412,7 @@ Inspired by the PowerShell VSCode extension: AST-powered tools that understand P | `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 | +| `Add-TrustedDirectory` / `Remove-TrustedDirectory [path]` | Trust a directory and approve its current `.psprc.ps1`; run again after the file changes | | `Set-TerminalBackground [-Opacity] [-StretchMode] [-Alignment]` | Set WT background image (live + persisted); `-Clear` to remove | ### Docker (when installed) @@ -398,15 +423,15 @@ Inspired by the PowerShell VSCode extension: AST-powered tools that understand P | `dimg` | List images | | `dlogs ` | Follow container logs | | `dex [shell]` | Exec into container | -| `dstop` | Stop all containers | -| `dprune` | System prune | +| `dstop` | Stop all running containers (asks first) | +| `dprune` | System prune (asks first) | ### SSH & Remote | 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) | +| `Copy-SshKey` / `ssh-copy-key ` | Copy SSH key to remote (when ssh installed); skips a key that is already there | | `keygen [name]` | Generate ED25519 key pair (when ssh installed) | | `rdp ` | Launch RDP session | @@ -435,20 +460,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) | -## 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). `lint` and `install-flow` are configured as required status checks in the repo's branch ruleset (a GitHub setting, not enforced by the workflow file itself). - ## 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. @@ -463,17 +474,43 @@ PowerShellPerfect bundles a **prompt** (via Oh My Posh), a **command suite**, an | Secret-scrubbed history | ✅ | — | — | — | | Customization surfaces | `user-settings.json` + `profile_user.ps1` + `plugins/` + `.psprc.ps1` | profile edits | themes | `starship.toml` | +## Limitations + +- **Windows only.** The profile and installer target Windows 10 1809+ and Windows 11 with Windows PowerShell 5.1 or PowerShell 7; they are not built for Linux or macOS. +- **Unsigned scripts.** Releases are checksummed and attested, not Authenticode-signed. Under an `AllSigned` execution policy you have to sign them yourself (setup offers to switch the CurrentUser policy to `RemoteSigned`). +- **Constrained Language Mode / WDAC / AppLocker.** Not supported: the profile compiles helper types with `Add-Type` and calls .NET APIs that Constrained Language Mode blocks, so it will not load under such a policy. +- **Single user, not a sandbox.** Commands run with your rights. `user-settings.json`, `profile_user.ps1`, `plugins\` and approved `.psprc.ps1` files are code you trust; protect them like any dotfile. +- **Third-party services.** Some commands send data to outside services (listed in [SECURITY.md](SECURITY.md#data-flows)); nothing is sent unless you run those commands. +- **Tested environments.** CI uses GitHub's Windows Server images with English display language; Windows 10/11 client and other display languages are checked by hand. + ## Troubleshooting | Symptom | Fix | | --- | --- | | Setup blocked by Windows Defender (Controlled Folder Access), often shown as `Could not find file` | Allow PowerShell through for the duration of the install and remove it afterwards (see [Install](#install-alternatives)). Moving the clone does not help: it is the write to `Documents` that is blocked. | -| `running scripts is disabled on this system` | Run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`. (The recommended one-liner runs an in-memory scriptblock, which isn't subject to the script-file execution policy.) | +| `running scripts is disabled on this system` | Run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`. (The install commands run an in-memory scriptblock, which isn't subject to the script-file execution policy.) | | 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`. | | Windows Terminal settings keep changing back | Another tool also writes `settings.json`. Pick one owner: set `windowsTerminal.manage` to `false` in `user-settings.json` to let the other tool have it, or stop the other tool. `psp-doctor` shows whether the file is a symlink and who manages it. | +| A `.psprc.ps1` stopped loading | It changed since you approved it (a pull, a checkout). Review it, then run `Add-TrustedDirectory` in that directory again. `psp-doctor` lists directories that need re-approval. | | Turned a feature off in the wizard but it still loads | Check the `features` block in `user-settings.json`, then restart PowerShell. | -| Something feels off | Run `psp-doctor` (alias for `Test-ProfileHealth`): OK/WARN/FAIL per check across tools, caches, fonts, PATH, and modules. | +| Something feels off | Run `psp-doctor` (alias for `Test-ProfileHealth`): OK/WARN/FAIL per check across version, tools, caches, fonts, PATH, and modules. Setup logs are in `%LOCALAPPDATA%\PowerShellProfile\logs`. | + +## Tests + +Everything in `tests/` is tracked. Run the suites from a normal (non-elevated) shell; they refuse to run elevated unless you pass `-AllowElevated`, which is meant for disposable VMs and CI runners. + +| File | Run | Purpose | +| --- | --- | --- | +| `tests/unit.ps1` | `pwsh -NoProfile -File tests/unit.ps1` (also with `powershell`) | Regression tests: Windows Terminal change records, uninstall semantics, `.psprc.ps1` approval, hash gates in `Update-Profile` and `setup.ps1`, wizard input checks, encoding, command guards. Fully sandboxed, no network | +| `tests/lint.ps1` | `pwsh -NoProfile -File tests/lint.ps1` | PSScriptAnalyzer 1.25.0 with `PSScriptAnalyzerSettings.psd1`, as in CI | +| `tests/smoke.ps1` | `pwsh -NoProfile -File tests/smoke.ps1` | Sandboxed non-interactive load with a load-time budget | +| `tests/test.ps1` | `pwsh -NoProfile -File tests/test.ps1` | Full local gate: lint, PS5 parse, static scans, sandboxed install/uninstall/lifecycle, both regression runs, every command in a sandbox, coverage audit. Commands that need the internet are skipped offline | +| `tests/ci-functional.ps1` | GitHub Actions `functional` job | Install via `setup.ps1 -LocalRepo -CiMode` into a sandbox, run the command matrix with third-party endpoints mocked, coverage audit (at least 60% of exports executed), uninstall. `-LiveNetwork` uses the real endpoints (weekly scheduled run) | +| `tests/rawhunt.ps1` | `pwsh -NoProfile -File tests/rawhunt.ps1 -AllowSideEffects` | Not sandboxed: exercises the real profile with real I/O (live services, clipboard, a public paste upload, winget). Opt-in only | +| `tests/locallab.ps1` | `pwsh -NoProfile -File tests/locallab.ps1` | Dev harness: runs the checks and optionally installs this checkout (`-Install`, `-FullInstall`, `-Wizard`); `-Restore` rolls back to the last backup | + +CI (`.github/workflows/ci.yml`) runs three jobs on every push/PR: **lint** (PSScriptAnalyzer, PS5 parse, sandboxed loads with a load-time budget, static scans), **install-flow** (config and pin checks, curated data, setup structure, regression tests on PS 5.1 and 7), and **functional** (the end-to-end suite above). `scheduled.yml` runs the functional suite against the real endpoints every week and checks that every pinned download still matches its hash; `release.yml` publishes tagged releases. Which jobs block a merge is a repository ruleset setting, not part of the workflow files. ## Roadmap @@ -481,7 +518,7 @@ Further ideas: - Per-distro WSL auto-configuration (install common tools when a new distro is detected). - `profile_user.ps1` scaffolder (`New-ProfileOverride` generates a commented starter file). -- Auto-fix mode for `psp-doctor` / `Test-ProfileHealth` — today it *diagnoses* (tools, caches, fonts, PATH, modules); add an opt-in `-Fix` that repairs the common issues it surfaces. +- 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/SECURITY.md b/SECURITY.md index 61104ed..21664f2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,13 @@ ## Supported Versions -Only the latest version on `main` is supported with security updates. +Security fixes go to `main` and ship in the next release. Only the latest release and `main` are supported. -| Branch | Supported | -| ------ | --------- | -| main | Yes | +| Version | Supported | +| ------- | --------- | +| Latest release (`vX.Y.Z`) | Yes | +| `main` | Yes | +| Older releases | No | ## Reporting a Vulnerability @@ -22,15 +24,26 @@ The following areas are in scope for security reports: - **Profile code** (`Microsoft.PowerShell_profile.ps1`) - command injection, unintended code execution - **Setup scripts** (`setup.ps1`, `setprofile.ps1`) - privilege escalation, unsafe downloads -- **Update mechanism** (`Update-Profile`) - hash verification bypass, MITM concerns +- **Update and release mechanism** (`Update-Profile`, `.github/workflows/release.yml`) - verification bypass, MITM concerns - **Credential handling** - API key exposure (e.g., VirusTotal), PSReadLine history filtering +## Verifying What You Install + +- **Releases.** Each tag `vX.Y.Z` is published by `release.yml` with `SHA256SUMS.txt` and a [build provenance attestation](https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations). The release files are the committed files of that tag, byte for byte what `raw.githubusercontent.com` serves for it. `gh attestation verify --repo 26zl/PowerShellPerfect` checks with Sigstore that a file was built by this repository's workflow from that tag; that check does not depend on the files or checksums being served correctly. +- **`setup.ps1 -Ref vX.Y.Z` and `Update-Profile -Ref vX.Y.Z`** resolve the tag to a commit, download every file from that commit, and compare each file with the release's `SHA256SUMS.txt` before anything is changed. The README shows how to check `setup.ps1` itself against the same file before running it. +- **Branches and commits** have no published checksum. Setup and `Update-Profile` then print the SHA-256 of what they downloaded and the commit it came from, and stop. `-ExpectedSha256` makes a later install reproducible and detects changes in transit, but the hash is computed over the same download, so it cannot tell you whether the bytes are what the maintainer intended: check the commit out-of-band first. `-SkipHashCheck` installs without any check (trust on first use). +- **Elevation.** `setup.ps1` needs no administrator rights and should be run unelevated. When it is elevated anyway, fonts and the telemetry opt-out are installed machine-wide and the terminal is not restarted, so you are not left in an elevated shell. `Invoke-ProfileWizard` runs the downloaded `setup.ps1` in the current session; for a release tag it checks `setup.ps1` against the release's `SHA256SUMS.txt` and passes the tag on, so setup checks the bundle the same way. +- **Downloaded scripts.** `winutil` hashes the downloaded script and runs those same in-memory bytes, so a file on disk cannot be swapped between the check and the run. A pinned or forced run needs an elevated shell: unelevated, WinUtil would relaunch itself as administrator from its latest release, which the hash check never saw. +- **Third-party components** are pinned: Oh My Posh themes to release `v31.8.0` with SHA-256 for the default and preset themes, Nerd Fonts to release `v3.5.1` with the SHA-256 of each curated archive, PSFzf and `http-server` (used by `serve`) to exact versions. A weekly workflow checks that every pinned hash still matches upstream. Themes picked from the wizard catalog, and theme URLs you set in `user-settings.json`, are fetched from the pinned release without a hash. +- **Software installs.** `setup.ps1` installs winget packages and accepts the winget source and package agreements for you; `Update-Profile` installs nothing unless you pass `-InstallMissingTools`. + ## Security Measures -- 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 +- PSReadLine history filters out lines containing: `password`, `secret`, `token`, `api[_-]?key`, `connectionstring`, `credential`, `bearer`, VirusTotal API key variable names, direct JWT values, value-shaped secrets (GitHub, AWS, Slack, Stripe, OpenAI-style keys, private keys, URLs with credentials), and sensitive command forms such as `pwnd ` and `jwtd `. +- `env` masks values whose names or shapes look like secrets unless you pass `-ShowValues`. `pwnd` prompts without echo when called without an argument. +- Repository download URLs are built in one place from the repository name and a ref. +- 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. +- Destructive commands refuse core Windows processes (`killport`, `pkill`, `Stop-StuckProcess`), ask before stopping processes or deleting (`Remove-LockedItem`, `Clear-Cache`, `dstop`, `dprune`), and `Clear-Cache` refuses a `TEMP` that does not look like a temp folder. `nscan` asks before its intrusive profiles; scan only systems you are authorized to test. ## Code-Execution Surfaces @@ -39,19 +52,30 @@ The profile has four mechanisms that execute code from places other than the mai - **`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`. +- **`.psprc.ps1` per-directory profiles** - auto-loaded on `cd` into a trusted directory. `Add-TrustedDirectory` stores the SHA-256 of the file as it is when you approve it, and the file runs only while it still has that hash. Any change, including one that arrives through `git pull`, `git checkout` or `gh pr checkout`, stops it from running (with a warning) until you review it and approve it again, as `direnv allow` does. The file is read once per directory change and the text that was hashed is the text that runs, so it cannot be swapped between the check and the run. Directories trusted by versions before 1.0.0 have no stored hash and need one new approval. Untrusted directories only print a notice. -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. +The first three need write access to your profile folders. `.psprc.ps1` files can arrive through version control, which is why their trust is bound to the approved content rather than the directory. -## Network Calls & Data Flow +## Data Flows -Commands that send *your* data to a third party (all over HTTPS): +Commands that send data to a third party (all over HTTPS). Nothing is sent unless you run the command. | Command | Endpoint | Data sent | | ------- | -------- | --------- | +| `pubip` | `ifconfig.me` | Your public IP (that is what it reports) | | `ipinfo [ip]` | `ipwho.is` | The IP you pass, or none (your public IP is then revealed to the service) | | `weather [city]` | `wttr.in`, fallback `open-meteo.com` + `ipinfo.io` | City name; with no city, your public IP is used for geolocation | +| `whois ` | `rdap.org` | The domain | +| `gitignore ` | `www.toptal.com` | The language names | +| `tldr ` | `raw.githubusercontent.com` (tldr-pages) | The command name (only when the native `tldr` client is not installed) | +| `pwnd [password]` | `api.pwnedpasswords.com` | The first 5 hex characters of the password's SHA-1 (k-anonymity); the password never leaves the machine | +| `speedtest` | `speed.cloudflare.com` | None beyond the connection (downloads 25 MB) | | `hb ` | `bin.christitus.com` | The **entire file contents**, posted to a public paste. Confirms first (`-Force`/`-Confirm:$false` to skip) | -| `vtscan ` | VirusTotal API | File SHA-256 first; uploads the file only if VT hasn't seen the hash. Needs `VTCLI_APIKEY`/`VT_API_KEY` | - -Commands that only contact targets you explicitly name (`http`, `whois`, `nslook`, `checkport`, `portscan`, `tlscert`, `speedtest`, `ssh`) send no profile-owned data. `Update-Profile`/`setup.ps1` and tool installs download from `raw.githubusercontent.com`, GitHub releases, and winget; they upload nothing. +| `vtscan [-Upload]` | VirusTotal API | The file's SHA-256; with `-Upload`, the file itself, which VirusTotal shares with its community and security partners. Needs `VTCLI_APIKEY`/`VT_API_KEY` | +| `serve` (npx fallback) | `registry.npmjs.org` | Downloads the pinned `http-server` package | +| `winutil` | `christitus.com` | None (downloads the WinUtil script) | +| `Update-PowerShell` | `api.github.com` | `$env:GITHUB_TOKEN` as a bearer token when it is set | +| `Update-Profile`, `setup.ps1`, the opt-in weekly update check | `api.github.com`, `raw.githubusercontent.com`, `github.com` release downloads | `$env:GITHUB_TOKEN` as a bearer token to `api.github.com` when it is set; nothing is uploaded | +| `setup.ps1` installs | winget sources, the PowerShell Gallery, `github.com` (Nerd Fonts), the Oh My Posh repository | None beyond the requests | + +Commands that only contact targets you explicitly name (`http`, `nslook`, `checkport`, `portscan`, `tlscert`, `certcheck`, `nscan`, `ssh`) send no profile-owned data. diff --git a/setprofile.ps1 b/setprofile.ps1 index 0680fdd..a187c73 100644 --- a/setprofile.ps1 +++ b/setprofile.ps1 @@ -1,36 +1,88 @@ +<# +.SYNOPSIS + Copy the profile from this checkout into the Windows PowerShell 5.1 and PowerShell 7 profile folders. + +.DESCRIPTION + Profile-only install for development. An existing profile is backed up as oldprofile..ps1 (the + newest five are kept) and, the first time, as profile.original.ps1, which Uninstall-Profile restores. + Tools, fonts, the theme and Windows Terminal settings are left alone; run setup.ps1 for those. + +.EXAMPLE + .\setprofile.ps1 + Copy the profile into both profile folders. + +.EXAMPLE + .\setprofile.ps1 -WhatIf + Show which files would be written. + +.NOTES + Exit codes: 0 = the profile was copied to every profile folder; 1 = the profile was not found or a copy failed. +#> +[CmdletBinding(SupportsShouldProcess = $true)] +param() + +Set-StrictMode -Version 1.0 +$ErrorActionPreference = 'Stop' + # Copy the profile to PS5 and PS7 directories while normalizing agent detection. if (-not [bool]$env:AI_AGENT -and ([bool]$env:AGENT_ID -or [bool]$env:CLAUDE_CODE -or [bool]$env:CODEX -or [bool]$env:CODEX_AGENT)) { $env:AI_AGENT = '1' } -if (-not $PSScriptRoot -or -not (Test-Path (Join-Path $PSScriptRoot "Microsoft.PowerShell_profile.ps1"))) { - Write-Error "Cannot find profile script. Run this script from the repo directory (e.g. .\setprofile.ps1)." +$source = if ($PSScriptRoot) { Join-Path $PSScriptRoot 'Microsoft.PowerShell_profile.ps1' } else { $null } +if (-not $source -or -not (Test-Path -LiteralPath $source -PathType Leaf)) { + Write-Error "Cannot find profile script. Run this script from the repo directory (e.g. .\setprofile.ps1)." -ErrorAction Continue exit 1 } +# Return $true when a file is a copy of this profile (its header line), so it is never kept as the user's original. +function Test-PspProfileFile([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } + [bool]@(Get-Content -LiteralPath $Path -TotalCount 3 -ErrorAction SilentlyContinue | Where-Object { $_ -match '^### PowerShell Profile \(26zl\)' }).Count +} + $docsRoot = Split-Path (Split-Path $PROFILE) $profileDirs = @( Join-Path $docsRoot "PowerShell" # PS7 (Core) Join-Path $docsRoot "WindowsPowerShell" # PS5 (Desktop) ) +$failed = 0 foreach ($dir in $profileDirs) { - if (!(Test-Path -Path $dir)) { - New-Item -Path $dir -ItemType "directory" -Force | Out-Null - } $targetProfile = Join-Path $dir "Microsoft.PowerShell_profile.ps1" - # Keep the latest five timestamped profile backups. - if (Test-Path -Path $targetProfile -PathType Leaf) { - $backupStamp = Get-Date -Format 'yyyyMMdd-HHmmss' - $backupPath = Join-Path $dir ("oldprofile.$backupStamp.ps1") - Copy-Item -Path $targetProfile -Destination $backupPath -Force - Write-Host " Backup saved to [$backupPath]" -ForegroundColor DarkGray - $oldBackups = Get-ChildItem -Path $dir -Filter 'oldprofile*.ps1' -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 - foreach ($old in $oldBackups) { - Remove-Item $old.FullName -Force -ErrorAction SilentlyContinue + if (-not $PSCmdlet.ShouldProcess($targetProfile, 'Copy the profile (the current one is backed up)')) { continue } + try { + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -Path $dir -ItemType Directory -Force | Out-Null } + if (Test-Path -LiteralPath $targetProfile -PathType Leaf) { + # Keep the pre-install profile once; Uninstall-Profile restores it and rotation never touches it. When the current + # profile is this one already, the oldest backup that is not is the user's; a saved copy of this profile is replaced. + $originalBackup = Join-Path $dir 'profile.original.ps1' + $originalIsOurs = Test-PspProfileFile $originalBackup + if ($originalIsOurs -or -not (Test-Path -LiteralPath $originalBackup)) { + $originalSource = if (-not (Test-PspProfileFile $targetProfile)) { $targetProfile } + else { + Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | + Where-Object { -not (Test-PspProfileFile $_.FullName) } | Select-Object -First 1 | ForEach-Object { $_.FullName } + } + if ($originalSource) { Copy-Item -LiteralPath $originalSource -Destination $originalBackup -Force } + elseif ($originalIsOurs) { Remove-Item -LiteralPath $originalBackup -Force } + } + # Keep the latest five timestamped profile backups; a failed backup stops the copy. + $backupPath = Join-Path $dir ("oldprofile.{0}.ps1" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + Copy-Item -LiteralPath $targetProfile -Destination $backupPath -Force + Write-Host " Backup saved to [$backupPath]" -ForegroundColor DarkGray + Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } + } + Copy-Item -LiteralPath $source -Destination $targetProfile -Force + Write-Host "Profile copied to $dir" -ForegroundColor Green + } + catch { + Write-Host "Failed to copy the profile to ${dir}: $($_.Exception.Message)" -ForegroundColor Red + $failed++ } - 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) { +if ([Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT -and -not $WhatIfPreference) { Write-Host "" Write-Host "Press Enter to close..." -ForegroundColor Yellow try { $null = Read-Host } catch { $null = $_ } } +exit ([int]($failed -gt 0)) diff --git a/setup.ps1 b/setup.ps1 index 0e4b804..0352cc0 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -1,5 +1,77 @@ -### Install PowerShellPerfect tools, fonts, themes, and Windows Terminal settings. +### Install PowerShellPerfect: profile, tools, fonts, theme, and Windows Terminal settings. +<# +.SYNOPSIS + Install the PowerShellPerfect profile with its tools, Nerd Font, Oh My Posh theme and Windows Terminal settings. + +.DESCRIPTION + Downloads Microsoft.PowerShell_profile.ps1, theme.json and terminal-config.json from one commit of + 26zl/PowerShellPerfect (or uses a local checkout), verifies them, and only then changes anything. + + No administrator rights are needed: the Nerd Font is installed for the current user and the telemetry + opt-out is set for the current user (both machine-wide when the shell is elevated). winget installs + eza, zoxide, fzf, bat, ripgrep and Oh My Posh and accepts the winget source and package agreements for you. + + Each run is logged to %LOCALAPPDATA%\PowerShellProfile\logs\setup-.log. Use -WhatIf to see the + changes without making them, and Uninstall-Profile to revert them. + +.PARAMETER Ref + Branch, release tag (vX.Y.Z) or commit to install from. The ref is resolved to a commit so all files come + from the same commit. A release tag is verified against the SHA256SUMS.txt published with the release. + +.PARAMETER ExpectedSha256 + Combined SHA-256 of the install bundle (profile, theme.json, terminal-config.json), as printed by a previous + run. Setup stops before changing anything when the downloaded bundle does not match. + +.PARAMETER SkipHashCheck + Install without verifying the bundle (trust on first use). + +.PARAMETER LocalRepo + Install from a local folder instead of downloading. A folder that holds setup.ps1 next to the profile files + is used automatically unless -Ref or -ExpectedSha256 is given. With -ExpectedSha256 the local files are checked. + A git checkout stores .ps1 files with CRLF line endings, so its hash differs from a release's (which covers the + published LF files); a local run prints the hash of the files it installs. + +.PARAMETER Wizard + Run the interactive wizard (theme, color scheme, font, editor, features) even when it would be skipped. + +.PARAMETER SkipWizard + Do not run the wizard. + +.PARAMETER Resume + Resume an interrupted wizard run. + +.PARAMETER CiMode + Non-interactive run for CI and tests: no winget, fonts, PowerShell Gallery modules or prompts. + +.PARAMETER Opacity + Windows Terminal opacity (0-100) instead of the terminal-config.json default. + +.PARAMETER ColorScheme + Windows Terminal color scheme name instead of the theme.json default. + +.PARAMETER FontSize + Windows Terminal font size instead of the terminal-config.json default. + +.EXAMPLE + & .\setup.ps1 -Ref v1.0.0 + Install release v1.0.0; the downloaded files are checked against the release's SHA256SUMS.txt. + +.EXAMPLE + & .\setup.ps1 -Ref main -ExpectedSha256 + Install the main branch pinned to the bundle hash printed by an earlier run. + +.EXAMPLE + .\setup.ps1 -WhatIf + Show what setup would change without changing anything. + +.NOTES + Exit codes: 0 = the profile is installed (optional tools that were skipped or failed are listed in the summary); + 1 = nothing was applied (verification failed, unsupported system) or the profile could not be installed. + When setup runs as a script block (the README install command) it sets $LASTEXITCODE instead of exiting and + removes the variables it created in the caller's session. Invoke-Expression (irm | iex) is not supported. +#> +[CmdletBinding(SupportsShouldProcess = $true)] param( [ValidateRange(0, 100)] [int]$Opacity = 75, @@ -20,9 +92,20 @@ param( [switch]$Resume, [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedSha256, - [switch]$SkipHashCheck + [switch]$SkipHashCheck, + + # Branch, tag (vX.Y.Z) or commit to install from. + [ValidatePattern('^(?!.*\.\.)(?!.*//)[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$')] + [string]$Ref = 'main' ) +Set-StrictMode -Version 1.0 +$ErrorActionPreference = 'Stop' + +# Run as a script block (the README install command), $script: is the caller's scope; the end of this file removes +# the variables setup adds there. +$callerScriptVariables = if ($PSCommandPath) { $null } else { @(Get-Variable -Scope Script -ErrorAction Ignore | ForEach-Object { $_.Name }) } + # Normalize known agent env vars to AI_AGENT (same as profile). if (-not [bool]$env:AI_AGENT -and ([bool]$env:AGENT_ID -or [bool]$env:CLAUDE_CODE -or [bool]$env:CODEX -or [bool]$env:CODEX_AGENT)) { $env:AI_AGENT = '1' @@ -33,33 +116,57 @@ if ($PSVersionTable.PSVersion.Major -lt 6) { try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { $null = $_ } } -$RepoBase = "https://raw.githubusercontent.com/26zl/PowerShellPerfect/main" -$script:DownloadedProfilePath = $null -$script:DownloadedThemeConfigPath = $null -$script:DownloadedTerminalConfigPath = $null -$script:VerifiedInstallBundle = $false - -# Use the adjacent local repository when available and -LocalRepo is omitted. -if ([string]::IsNullOrWhiteSpace($LocalRepo) -and $PSScriptRoot -and - (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'Microsoft.PowerShell_profile.ps1')) -and - (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'theme.json')) -and - (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'terminal-config.json'))) { - $LocalRepo = $PSScriptRoot - Write-Host "Using local repo checkout: $LocalRepo" -ForegroundColor DarkGray -} - -function Get-CombinedSha256 { - param([Parameter(Mandatory)][string[]]$Parts) - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - return [BitConverter]::ToString( - $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($Parts -join ':'))) - ).Replace('-', '') +$script:RepoOwner = '26zl' +$script:RepoName = 'PowerShellPerfect' +# Nerd Fonts release the curated fonts are pinned to; each Sha256 below comes from that release's SHA-256.txt. +$script:NerdFontVersion = '3.5.1' +# Oh My Posh release the wizard's theme catalog and the preset themes come from. +$script:OmpThemesRef = 'v31.8.0' +$script:PinnedOmpThemes = @{ + pure = 'E0786DA374967BF4B4D3B107580AC86246C132679C309291F378D73C1E804E5A' + atomic = '30B2794D47C4015BD3B83680D314F9884050493E7E26B638D0CDA4A97442B2AF' +} +$script:BoundParameterNames = @($PSBoundParameters.Keys) +$script:Bundle = $null +$script:WorkDir = $null +$script:SourceCommit = $null +$script:BundleSha256 = $null +$script:WizardFontInstalled = $false +$script:WizardOmpTheme = $null +$script:WizardEditor = $null +$script:WizardTelemetryHandled = $false +$script:TranscriptStarted = $false +$script:ErrorReported = $false +$script:IsElevated = $false +try { $script:IsElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { $null = $_ } +$script:IsCiHost = $CiMode -or [bool]$env:GITHUB_ACTIONS -or [bool]$env:CI +$script:CanPrompt = [Environment]::UserInteractive -and -not $script:IsCiHost -and -not [bool]$env:AI_AGENT +if ($script:CanPrompt) { try { $null = [Console]::KeyAvailable } catch { $script:CanPrompt = $false } } + +# Write host output, dropping colors when the NO_COLOR environment variable is set (https://no-color.org). +function Write-PspHost { + [CmdletBinding()] + param( + [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromRemainingArguments = $true)][object[]]$Object, + [System.ConsoleColor]$ForegroundColor, + [System.ConsoleColor]$BackgroundColor, + [switch]$NoNewline, + [object]$Separator + ) + process { + $splat = @{} + if ($null -ne $Object) { $splat.Object = $(if ($Object.Count -eq 1) { $Object[0] } else { $Object }) } + if ($NoNewline) { $splat.NoNewline = $true } + if ($PSBoundParameters.ContainsKey('Separator')) { $splat.Separator = $Separator } + if ([string]::IsNullOrEmpty($env:NO_COLOR)) { + if ($PSBoundParameters.ContainsKey('ForegroundColor')) { $splat.ForegroundColor = $ForegroundColor } + if ($PSBoundParameters.ContainsKey('BackgroundColor')) { $splat.BackgroundColor = $BackgroundColor } + } + Write-Host @splat } - finally { $sha.Dispose() } } -# Download with retries, size validation, and corrupt-file cleanup. +# Download helper with retry, size validation, and corrupt-file cleanup function Invoke-DownloadWithRetry { param( [Parameter(Mandatory)] @@ -72,17 +179,17 @@ function Invoke-DownloadWithRetry { ) for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { - Remove-Item $OutFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $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 + if (-not (Test-Path -LiteralPath $OutFile) -or (Get-Item -LiteralPath $OutFile).Length -eq 0) { + Remove-Item -LiteralPath $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 + Write-PspHost " Download failed (attempt $attempt/$MaxAttempts): $_ Retrying in ${BackoffSec}s..." -ForegroundColor Yellow Start-Sleep -Seconds $BackoffSec } else { @@ -92,6 +199,71 @@ function Invoke-DownloadWithRetry { } } +# Return the combined SHA-256 that pins a whole install bundle (profile, theme.json, terminal-config.json). +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() } +} + +# Resolve a branch, tag or commit to its full commit SHA through the GitHub API ($null when offline or rate-limited). +function Resolve-PspCommitSha { + param( + [Parameter(Mandatory)][string]$Owner, + [Parameter(Mandatory)][string]$Repo, + [Parameter(Mandatory)][string]$Ref, + [int]$TimeoutSec = 10 + ) + if ($Ref -match '^[0-9a-fA-F]{40}$') { return $Ref.ToLowerInvariant() } + try { + $uri = 'https://api.github.com/repos/{0}/{1}/commits/{2}' -f $Owner, $Repo, [Uri]::EscapeDataString($Ref) + $headers = @{ 'User-Agent' = 'PowerShellPerfect'; Accept = 'application/vnd.github.sha' } + if ($env:GITHUB_TOKEN) { $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" } + $sha = [string](Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop) + if ($sha.Trim() -match '^[0-9a-f]{40}$') { return $sha.Trim() } + } + catch { $null = $_ } + return $null +} + +# Return the SHA-256 values a release publishes in SHA256SUMS.txt, keyed by file name ($null when the ref is not a release tag or the file is unavailable). +function Get-PspReleaseChecksums { + param( + [Parameter(Mandatory)][string]$Owner, + [Parameter(Mandatory)][string]$Repo, + [Parameter(Mandatory)][string]$Tag, + [int]$TimeoutSec = 15 + ) + if ($Tag -notmatch '^v\d+\.\d+\.\d+$') { return $null } + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ('psp-sums-' + [System.IO.Path]::GetRandomFileName()) + try { + $uri = 'https://github.com/{0}/{1}/releases/download/{2}/SHA256SUMS.txt' -f $Owner, $Repo, $Tag + Invoke-RestMethod -Uri $uri -OutFile $tmp -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + $sums = @{} + foreach ($line in [System.IO.File]::ReadAllLines($tmp)) { + if ($line -match '^([0-9A-Fa-f]{64})\s+\*?(\S+)\s*$') { $sums[$Matches[2]] = $Matches[1].ToUpperInvariant() } + } + if ($sums.Count -eq 0) { return $null } + return $sums + } + catch { return $null } + finally { [System.IO.File]::Delete($tmp) } +} + +# Run a native command and capture its output; stderr never becomes a terminating error under ErrorActionPreference Stop. +function Invoke-SetupNative { + param([Parameter(Mandatory)][string]$FilePath, [string[]]$ArgumentList = @()) + $ErrorActionPreference = 'Continue' + $output = & $FilePath @ArgumentList 2>&1 | Out-String + [PSCustomObject]@{ ExitCode = $LASTEXITCODE; Output = $output } +} + +# Delete a temporary folder this script created, only when it has the expected prefix and sits under the temp folder. function Remove-SafeTempDirectory { param( [AllowNull()][string]$Path, @@ -104,84 +276,121 @@ function Remove-SafeTempDirectory { $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 + Remove-Item -LiteralPath $rootFull -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false } else { - Write-Host " Skipped temp cleanup outside expected temp root: $rootFull" -ForegroundColor Yellow + Write-PspHost " Skipped temp cleanup outside expected temp root: $rootFull" -ForegroundColor Yellow } } -function Initialize-RemoteInstallBundle { - if ($LocalRepo -or $script:VerifiedInstallBundle) { return } - - $tempSuffix = [System.IO.Path]::GetRandomFileName() - $bundleProfile = Join-Path $env:TEMP "psp-setup-profile-$tempSuffix.ps1" - $bundleTheme = Join-Path $env:TEMP "psp-setup-theme-$tempSuffix.json" - $bundleTerminal = Join-Path $env:TEMP "psp-setup-terminal-$tempSuffix.json" - +# Return $true when github.com answers over HTTPS (works through corporate proxies and firewalls). +function Test-InternetConnection { 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 + $response = Invoke-WebRequest -Uri 'https://github.com' -Method Head -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop + return $response.StatusCode -eq 200 } catch { - Remove-Item $bundleProfile, $bundleTheme, $bundleTerminal -Force -ErrorAction SilentlyContinue - throw + Write-PspHost 'Internet connection is required but not available (cannot reach github.com).' -ForegroundColor Red + return $false } } -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) +# Locate or download the profile, theme.json and terminal-config.json and verify them before anything is changed. +function Initialize-InstallBundle { + $names = [ordered]@{ Profile = 'Microsoft.PowerShell_profile.ps1'; Theme = 'theme.json'; Terminal = 'terminal-config.json' } + $files = @{} 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') } + foreach ($key in $names.Keys) { + $candidate = Join-Path $LocalRepo $names[$key] + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { throw "Local repo is missing $($names[$key]): $LocalRepo" } + $files[$key] = (Resolve-Path -LiteralPath $candidate).ProviderPath + } + } + else { + if (-not (Test-InternetConnection)) { throw 'No internet connection.' } + # Resolve the ref once so all three files come from the same commit (the ref itself is used when the API is unreachable). + $script:SourceCommit = Resolve-PspCommitSha -Owner $script:RepoOwner -Repo $script:RepoName -Ref $Ref + $sourceRef = if ($script:SourceCommit) { $script:SourceCommit } else { $Ref } + $script:WorkDir = Join-Path ([System.IO.Path]::GetTempPath()) ('psp-setup-' + [System.IO.Path]::GetRandomFileName()) + [void][System.IO.Directory]::CreateDirectory($script:WorkDir) + Write-PspHost "Downloading the install bundle from $($script:RepoOwner)/$($script:RepoName)@$sourceRef" -ForegroundColor DarkGray + foreach ($key in $names.Keys) { + $target = Join-Path $script:WorkDir $names[$key] + $uri = 'https://raw.githubusercontent.com/{0}/{1}/{2}/{3}' -f $script:RepoOwner, $script:RepoName, $sourceRef, $names[$key] + Invoke-DownloadWithRetry -Uri $uri -OutFile $target -TimeoutSec 30 + $files[$key] = $target } } - Initialize-RemoteInstallBundle - switch ($Kind) { - 'profile' { return $script:DownloadedProfilePath } - 'theme' { return $script:DownloadedThemeConfigPath } - 'terminal' { return $script:DownloadedTerminalConfigPath } + $hashes = @{} + foreach ($key in $names.Keys) { $hashes[$key] = (Get-FileHash -LiteralPath $files[$key] -Algorithm SHA256).Hash } + $combined = Get-CombinedSha256 -Parts @("profile:$($hashes.Profile)", "theme:$($hashes.Theme)", "terminal:$($hashes.Terminal)") + $script:BundleSha256 = $combined + + if ($SkipHashCheck) { + Write-PspHost 'Install bundle not verified (-SkipHashCheck).' -ForegroundColor Yellow + } + elseif ($ExpectedSha256) { + if ($combined -ne $ExpectedSha256.ToUpperInvariant()) { + # A git checkout converts .ps1 files to CRLF, so a release hash never matches it. + $localHint = if ($LocalRepo) { ' Release hashes cover the published files; a git checkout has CRLF line endings and hashes differently.' } else { '' } + throw "Combined hash mismatch. Expected $($ExpectedSha256.ToUpperInvariant()), got $combined.$localHint" + } + Write-PspHost "Install bundle verified: $combined" -ForegroundColor Green + } + elseif ($LocalRepo) { + Write-PspHost "Installing from local files in $LocalRepo (not hash-checked). Bundle hash of these files: $combined" -ForegroundColor DarkGray + } + else { + # A release tag is checked against the SHA256SUMS.txt published with that release. + $sums = Get-PspReleaseChecksums -Owner $script:RepoOwner -Repo $script:RepoName -Tag $Ref + if ($sums) { + foreach ($key in $names.Keys) { + if ($sums[$names[$key]] -ne $hashes[$key]) { throw "$($names[$key]) does not match the SHA256SUMS.txt of release $Ref." } + } + Write-PspHost "Install bundle matches the SHA256SUMS.txt of release $Ref." -ForegroundColor Green + } + else { + $sourceRef = if ($script:SourceCommit) { $script:SourceCommit } else { $Ref } + Write-PspHost 'Downloaded install bundle hashes:' -ForegroundColor Yellow + Write-PspHost " profile.ps1: $($hashes.Profile)" -ForegroundColor Yellow + Write-PspHost " theme.json: $($hashes.Theme)" -ForegroundColor Yellow + Write-PspHost " terminal-config: $($hashes.Terminal)" -ForegroundColor Yellow + Write-PspHost " combined: $combined" -ForegroundColor Yellow + Write-PspHost 'Release tags (-Ref vX.Y.Z) are verified automatically against the release''s SHA256SUMS.txt.' -ForegroundColor DarkYellow + Write-PspHost "For a branch or commit, check it out-of-band first: https://github.com/$($script:RepoOwner)/$($script:RepoName)/commit/$sourceRef" -ForegroundColor DarkYellow + throw "Hash input required. Re-run with -Ref $sourceRef -ExpectedSha256 '$combined' (or -SkipHashCheck)." + } + } + return [PSCustomObject]$files +} + +# Shared helpers are loaded from the verified profile, so setup and the profile run the same code. +$script:SharedFunctionNames = @( + 'Get-PspCacheDir', 'ConvertTo-NativeArgumentLine', 'Get-Utf8FileText', 'Write-Utf8FileAtomic', 'ConvertFrom-JsonC', 'Read-JsonFile', + 'Get-PspProp', 'Get-PspArray', 'Get-PspPath', 'Test-PspProp', 'Set-PspProp', 'Remove-PspProp', 'Copy-PspObject', + 'ConvertTo-PspCanonicalJson', 'Test-PspJsonEqual', 'Get-PspComponentPins', 'Get-PspInstallState', 'Set-PspInstallState', + 'Get-ExternalCommandPath', 'Update-SessionPathFromRegistry', 'Get-WindowsTerminalSettingsPaths', 'Merge-JsonObject', + 'Read-WtChangeManifest', 'Save-WtChangeManifest', 'Add-WtChangeRecord', 'Set-WtRecordedValue', 'Set-WtRecordedArrayItem', + 'Set-WtRecordedKeybinding', 'Set-WtRecordedNoLogo', 'Update-WindowsTerminalSettingsFile', 'Get-OhMyPoshExecutablePath', + 'Get-PspUserSettingsTemplate', 'Merge-PspUserSettings', 'Test-PspTrustedThemeUrl', 'Read-UserSettingsForWrite', 'Get-PspShellPath', + 'Get-WtProfileList', 'Test-PspProfileFile', 'Get-PspOriginalProfileSource' +) + +# Return the source text of the shared helper functions defined at the top level of a profile file. +function Get-SharedFunctionDefinitions { + param([Parameter(Mandatory)][string]$ProfilePath, [Parameter(Mandatory)][string[]]$Names) + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($ProfilePath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors -and $parseErrors.Count -gt 0) { throw "The profile has $($parseErrors.Count) parse error(s): $($parseErrors[0].Message)" } + $defined = @{} + foreach ($statement in $ast.EndBlock.Statements) { + if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) { $defined[$statement.Name] = $statement.Extent.Text } } + $missing = @($Names | Where-Object { -not $defined.ContainsKey($_) }) + if ($missing.Count -gt 0) { throw "The profile does not define: $($missing -join ', ')" } + foreach ($name in $Names) { $defined[$name] } } # Define the install wizard's curated Windows Terminal color schemes. @@ -217,15 +426,35 @@ $script:CuratedSchemes = @( ) # Map curated Nerd Font release assets to their Windows display names; Name is the menu label Select-WizardItem renders and matches on. +# Sha256 is the hash of .zip in Nerd Fonts $script:NerdFontVersion. $script:CuratedFonts = @( - @{ Name = 'CascadiaCode'; Asset = 'CascadiaCode'; DisplayName = 'CaskaydiaCove NF'; Desc = 'Microsoft Cascadia + icons (default)' } - @{ Name = 'JetBrainsMono'; Asset = 'JetBrainsMono'; DisplayName = 'JetBrainsMono NF'; Desc = 'JetBrains flagship, tight + readable' } - @{ Name = 'FiraCode'; Asset = 'FiraCode'; DisplayName = 'FiraCode NF'; Desc = 'Popular ligature font' } - @{ Name = 'Meslo'; Asset = 'Meslo'; DisplayName = 'MesloLGM NF'; Desc = 'p10k default, excellent rendering' } - @{ Name = 'Hack'; Asset = 'Hack'; DisplayName = 'Hack NF'; Desc = 'Simple + workhorse' } - @{ Name = 'Iosevka'; Asset = 'Iosevka'; DisplayName = 'Iosevka NF'; Desc = 'Narrow monospace, space-efficient' } + @{ Name = 'CascadiaCode'; Asset = 'CascadiaCode'; DisplayName = 'CaskaydiaCove NF'; Desc = 'Microsoft Cascadia + icons (default)'; Sha256 = '1298BF92698AFA06185CF1D05E6AE05F2D8A1E8C3CB45DDF4C3035168AB342A1' } + @{ Name = 'JetBrainsMono'; Asset = 'JetBrainsMono'; DisplayName = 'JetBrainsMono NF'; Desc = 'JetBrains flagship, tight + readable'; Sha256 = 'FAB782A66F7D3019DA64F6572DB9FC5D3A4BCB19F9FA13E2D8A62E3693D6396E' } + @{ Name = 'FiraCode'; Asset = 'FiraCode'; DisplayName = 'FiraCode NF'; Desc = 'Popular ligature font'; Sha256 = '239395BAF60C89B2EAF4862B6B09DB0EF95605CD3E8EEF51C00345822A81A665' } + @{ Name = 'Meslo'; Asset = 'Meslo'; DisplayName = 'MesloLGM NF'; Desc = 'p10k default, excellent rendering'; Sha256 = 'FB104893ECD8F57E8AFACBC0A7086B42657120448D056D6093C728A9AFB8E237' } + @{ Name = 'Hack'; Asset = 'Hack'; DisplayName = 'Hack NF'; Desc = 'Simple + workhorse'; Sha256 = 'FA24DA7DE7CEFE7766614D27762570B20453C852FC1D5B657111666DF9A5E449' } + @{ Name = 'Iosevka'; Asset = 'Iosevka'; DisplayName = 'Iosevka NF'; Desc = 'Narrow monospace, space-efficient'; Sha256 = '0512C8561FDCFED03C5CAE130D1C01F279CFAD19467337F9EAB98A2B730ECC50' } +) + +# Define editor choices and optional winget package IDs. +# Defined before the wizard runs: PowerShell only registers a function once its +# definition statement executes, so anything Start-InstallWizard calls must appear above it. +$EditorCandidates = @( + @{ Cmd = 'code'; Display = 'Visual Studio Code'; WingetId = 'Microsoft.VisualStudioCode' } + @{ Cmd = 'nvim'; Display = 'Neovim'; WingetId = 'Neovim.Neovim' } + @{ Cmd = 'vim'; Display = 'Vim'; WingetId = 'vim.vim' } + @{ Cmd = 'msedit'; Display = 'Microsoft Edit'; WingetId = 'Microsoft.Edit' } + @{ Cmd = 'subl'; Display = 'Sublime Text'; WingetId = 'SublimeHQ.SublimeText.4' } + @{ Cmd = 'notepad++'; Display = 'Notepad++'; WingetId = 'Notepad++.Notepad++' } + @{ Cmd = 'notepad'; Display = 'Notepad (always available)'; WingetId = $null } ) +# Return the raw URL of an Oh My Posh theme at the pinned Oh My Posh release. +function Get-OmpThemeUrl { + param([Parameter(Mandatory)][string]$Name, [string]$Ref = $script:OmpThemesRef) + 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/{0}/themes/{1}.omp.json' -f $Ref, $Name +} + # Return one wizard choice from stdin, Out-GridView, or fzf. function Select-WizardItem { param( @@ -235,13 +464,13 @@ function Select-WizardItem { [string]$Note, # explanation line printed under the title [switch]$AllowSkip # Enter with no default = skip ) - Write-Host '' - Write-Host ("-- {0} --" -f $Title) -ForegroundColor Cyan - if ($Note) { Write-Host $Note } + Write-PspHost '' + Write-PspHost ("-- {0} --" -f $Title) -ForegroundColor Cyan + if ($Note) { Write-PspHost $Note } 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) + Write-PspHost (" {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" @@ -256,54 +485,41 @@ function Select-WizardItem { 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 + # Allow a partial name too; wildcard characters in the reply are matched literally. + $pattern = '*' + [WildcardPattern]::Escape($raw) + '*' + $match = $Items | Where-Object { $_.Name -like $pattern } | Select-Object -First 1 if ($match) { return $match } - Write-Host " Invalid; try again." -ForegroundColor Yellow + Write-PspHost " Invalid; try again." -ForegroundColor Yellow } while ($true) } -# Return the latest Nerd Fonts version with configured and hardcoded fallbacks. -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' -} - -# Return Oh My Posh theme names and URLs from GitHub or an empty array on failure. +# Return the Oh My Posh themes of the pinned release; URLs are built from the pinned ref, not taken from the API. function Get-OmpThemeList { - $apiUrl = 'https://api.github.com/repos/JanDeDobbeleer/oh-my-posh/contents/themes?ref=main' + param([string]$Ref = $script:OmpThemesRef) + $apiUrl = 'https://api.github.com/repos/JanDeDobbeleer/oh-my-posh/contents/themes?ref={0}' -f [Uri]::EscapeDataString($Ref) 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 } - } + $themes = foreach ($item in @($resp)) { + $itemName = [string](Get-PspProp $item 'name') + if ($itemName -notmatch '^([A-Za-z0-9._-]+)\.omp\.json$') { continue } + $baseName = $Matches[1] + @{ Name = $baseName; Desc = ''; Url = (Get-OmpThemeUrl -Name $baseName -Ref $Ref); Sha256 = $script:PinnedOmpThemes[$baseName] } } - return $themes | Sort-Object { $_.Name } + return @($themes | Sort-Object { $_.Name }) } -# Merge non-null wizard choices into user-settings.json. +# Merge non-null wizard choices into user-settings.json; a file that exists but does not parse is never overwritten. function Save-WizardChoices { + [CmdletBinding(SupportsShouldProcess = $true)] 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 -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop) } - catch { [PSCustomObject]@{} } - } - else { [PSCustomObject]@{} } - if ($null -eq $s) { $s = [PSCustomObject]@{} } + # Comments and trailing commas are accepted on both PowerShell editions. + try { $s = Read-UserSettingsForWrite -Path $UserSettingsPath } + catch { throw "user-settings.json could not be read ($($_.Exception.Message)). Fix or move $UserSettingsPath and run the wizard again; nothing was saved." } # The wizard writes this file before the install steps lay down their template, which is then # skipped as "already exists", so carry the header that explains what the file is for. @@ -313,7 +529,10 @@ function Save-WizardChoices { # theme (OMP) if ($Choices.Theme) { - $s | Add-Member -NotePropertyName 'theme' -NotePropertyValue ([PSCustomObject]@{ name = $Choices.Theme.Name; url = $Choices.Theme.Url }) -Force + $themeObj = [PSCustomObject]@{ name = $Choices.Theme.Name; url = $Choices.Theme.Url } + $themeSha = Get-PspProp $Choices.Theme 'Sha256' + if ($themeSha) { $themeObj | Add-Member -NotePropertyName 'sha256' -NotePropertyValue $themeSha -Force } + $s | Add-Member -NotePropertyName 'theme' -NotePropertyValue $themeObj -Force } # windowsTerminal.colorScheme + scheme (color scheme) if ($Choices.Scheme) { @@ -351,7 +570,7 @@ function Save-WizardChoices { } # Store terminal appearance defaults while preserving the selected font face. if ($Choices.Terminal) { - foreach ($k in $Choices.Terminal.Keys) { + 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]@{} } @@ -366,7 +585,7 @@ function Save-WizardChoices { # features if ($Choices.Features) { if (-not $s.PSObject.Properties['features']) { $s | Add-Member -NotePropertyName 'features' -NotePropertyValue ([PSCustomObject]@{}) -Force } - foreach ($k in $Choices.Features.Keys) { + foreach ($k in @($Choices.Features.Keys)) { $s.features | Add-Member -NotePropertyName $k -NotePropertyValue $Choices.Features[$k] -Force } } @@ -388,8 +607,11 @@ function Save-WizardChoices { 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) + if (-not $PSCmdlet.ShouldProcess($UserSettingsPath, 'Save wizard choices')) { return } + $dir = Split-Path -Parent $UserSettingsPath + if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + # The previous file is kept as user-settings.json.bak (its comments are not carried into the rewritten file). + Write-Utf8FileAtomic -Path $UserSettingsPath -Content ($s | ConvertTo-Json -Depth 20) -BackupPath "$UserSettingsPath.bak" } # Return a yes/no choice with Enter mapped to the default. @@ -401,12 +623,125 @@ function Read-WizardYesNo { return ($raw -match '^(?i:y|yes)$') } +# Rebuild resumed wizard choices from the curated lists and range checks, so an edited state file cannot inject values. +function Resolve-WizardState { + param([AllowNull()][object]$Saved) + $steps = @('Theme', 'Scheme', 'Font', 'TabBar', 'Terminal', 'PSReadLine', 'Background', 'Editor', 'Telemetry', 'Features') + $valid = @{ + Theme = $null; Scheme = $null; Font = $null; Features = $null; Background = $null; TabBar = $null; AppTheme = $null + Terminal = $null; PSReadLine = $null; Editor = $null; TelemetryOptOut = $null; CompletedSteps = @() + } + $theme = Get-PspProp $Saved 'Theme' + $themeName = [string](Get-PspProp $theme 'Name') + $themeUrl = [string](Get-PspProp $theme 'Url') + if ($themeName -match '^[A-Za-z0-9._-]+$' -and (Test-PspTrustedThemeUrl $themeUrl)) { + $themeSha = [string](Get-PspProp $theme 'Sha256') + $valid.Theme = @{ Name = $themeName; Url = $themeUrl; Sha256 = $(if ($themeSha -match '^[A-Fa-f0-9]{64}$') { $themeSha } else { $null }) } + } + $schemeName = [string](Get-PspProp (Get-PspProp $Saved 'Scheme') 'name') + $curatedScheme = $script:CuratedSchemes | Where-Object { $_.Scheme.name -eq $schemeName } | Select-Object -First 1 + if ($curatedScheme) { $valid.Scheme = $curatedScheme.Scheme } + $fontName = [string](Get-PspProp (Get-PspProp $Saved 'Font') 'Name') + $curatedFont = $script:CuratedFonts | Where-Object { $_.Name -eq $fontName } | Select-Object -First 1 + if ($curatedFont) { $valid.Font = $curatedFont } + $tabBar = [string](Get-PspProp $Saved 'TabBar') + if ($tabBar -match '^#[0-9A-Fa-f]{6}$') { $valid.TabBar = $tabBar } + $appTheme = [string](Get-PspProp $Saved 'AppTheme') + if ($appTheme -in 'dark', 'light') { $valid.AppTheme = $appTheme } + $terminal = Get-PspProp $Saved 'Terminal' + if ($terminal) { + $term = [ordered]@{} + $intRanges = @{ opacity = @(1, 100); fontSize = @(8, 24); historySize = @(100, 1000000) } + foreach ($key in $intRanges.Keys) { + $n = 0 + if ([int]::TryParse([string](Get-PspProp $terminal $key), [ref]$n) -and $n -ge $intRanges[$key][0] -and $n -le $intRanges[$key][1]) { $term[$key] = $n } + } + if ((Get-PspProp $terminal 'useAcrylic') -eq $true) { $term['useAcrylic'] = $true } + $cursor = [string](Get-PspProp $terminal 'cursorShape') + if ($cursor -in 'bar', 'block', 'vintage', 'emptyBox', 'filledBox', 'doubleUnderscore') { $term['cursorShape'] = $cursor } + $padding = [string](Get-PspProp $terminal 'padding') + if ($padding -match '^(\d{1,2}), \1, \1, \1$' -and [int]$Matches[1] -le 50) { $term['padding'] = $padding } + $scrollbar = [string](Get-PspProp $terminal 'scrollbarState') + if ($scrollbar -in 'visible', 'hidden', 'always') { $term['scrollbarState'] = $scrollbar } + if ($term.Count -gt 0) { $valid.Terminal = $term } + } + $readline = [string](Get-PspProp $Saved 'PSReadLine') + if ($readline -in 'default', 'scheme') { $valid.PSReadLine = $readline } + $background = Get-PspProp $Saved 'Background' + $bgPath = [string](Get-PspProp $background 'Path') + $bgOpacity = 0.0 + if ($bgPath -and (Test-Path -LiteralPath $bgPath -PathType Leaf) -and [double]::TryParse([string](Get-PspProp $background 'Opacity'), [ref]$bgOpacity) -and $bgOpacity -ge 0.05 -and $bgOpacity -le 0.5) { + $valid.Background = @{ Path = (Resolve-Path -LiteralPath $bgPath).ProviderPath; Opacity = $bgOpacity } + } + $editor = [string](Get-PspProp $Saved 'Editor') + if ($EditorCandidates | Where-Object { $_.Cmd -eq $editor }) { $valid.Editor = $editor } + $telemetry = Get-PspProp $Saved 'TelemetryOptOut' + if ($telemetry -is [bool]) { $valid.TelemetryOptOut = $telemetry } + $features = Get-PspProp $Saved 'Features' + if ($features) { + $known = [ordered]@{} + foreach ($key in 'psfzf', 'predictions', 'startupMessage', 'perDirProfiles', 'commandOverrides') { + $value = Get-PspProp $features $key + if ($value -is [bool]) { $known[$key] = $value } + } + if ($known.Count -gt 0) { $valid.Features = $known } + } + $valid.CompletedSteps = @((Get-PspArray $Saved 'CompletedSteps') | Where-Object { $steps -contains [string]$_ } | ForEach-Object { [string]$_ } | Select-Object -Unique) + return $valid +} + +# Interactive editor preference prompt - returns chosen Cmd string +function Select-PreferredEditor { + $defaultChoice = $null + Write-PspHost "" + Write-PspHost " Select your preferred code editor:" -ForegroundColor Cyan + Write-PspHost "" + for ($i = 0; $i -lt $EditorCandidates.Count; $i++) { + $ed = $EditorCandidates[$i] + $num = $i + 1 + $installed = [bool](Get-Command $ed.Cmd -ErrorAction Ignore) + if ($installed) { + if ($null -eq $defaultChoice) { $defaultChoice = $i } + Write-PspHost " $num) $($ed.Display) ($($ed.Cmd)) " -NoNewline -ForegroundColor White + Write-PspHost '[installed]' -ForegroundColor Green + } + else { + Write-PspHost " $num) $($ed.Display) ($($ed.Cmd))" -ForegroundColor DarkGray + } + } + if ($null -eq $defaultChoice) { $defaultChoice = 0 } + $defaultNum = $defaultChoice + 1 + Write-PspHost "" + $reply = Read-Host " Choice [$defaultNum]" + if ([string]::IsNullOrWhiteSpace($reply)) { + return $EditorCandidates[$defaultChoice].Cmd + } + $parsed = 0 + $chosen = $null + if ([int]::TryParse($reply, [ref]$parsed) -and $parsed -ge 1 -and $parsed -le $EditorCandidates.Count) { + $chosen = $EditorCandidates[$parsed - 1] + } + else { + Write-PspHost " Invalid choice, using default." -ForegroundColor Yellow + return $EditorCandidates[$defaultChoice].Cmd + } + if (-not (Get-Command $chosen.Cmd -ErrorAction Ignore)) { + Write-PspHost " '$($chosen.Cmd)' is not installed." -ForegroundColor Yellow + $confirm = Read-Host " Use anyway? [y/N]" + if ($confirm -notmatch '^[Yy]') { + Write-PspHost " Using default instead." -ForegroundColor Yellow + return $EditorCandidates[$defaultChoice].Cmd + } + } + return $chosen.Cmd +} + # Return the main wizard choices as a hashtable for Save-WizardChoices. function Start-InstallWizard { param([string]$StatePath) $choices = @{ - Theme = $null # @{ Name='atomic'; Url='...' } + Theme = $null # @{ Name='atomic'; Url='...'; Sha256='...' } Scheme = $null # full scheme hashtable Font = $null # curated font entry Features = $null # hashtable @@ -416,26 +751,26 @@ function Start-InstallWizard { 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, ...) - TelemetryOptOut = $null # $true = set POWERSHELL_TELEMETRY_OPTOUT machine-wide + TelemetryOptOut = $null # $true = set POWERSHELL_TELEMETRY_OPTOUT (user, or machine-wide when elevated) CompletedSteps = @() } # Increment the state version whenever the wizard choice schema changes. - $WIZARD_STATE_SCHEMA = 2 + $WIZARD_STATE_SCHEMA = 3 # Resume from state file if caller passed one that exists - if ($StatePath -and (Test-Path $StatePath)) { + if ($StatePath -and (Test-Path -LiteralPath $StatePath)) { try { - $prev = Get-Content $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json - $prevSchema = if ($prev.PSObject.Properties['schemaVersion']) { [int]$prev.schemaVersion } else { 1 } + $prev = Read-JsonFile $StatePath + $prevSchema = if (Test-PspProp $prev '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 + Write-PspHost '' + Write-PspHost ("Wizard state schema mismatch (found v{0}, expected v{1}); starting fresh." -f $prevSchema, $WIZARD_STATE_SCHEMA) -ForegroundColor Yellow + Remove-Item -LiteralPath $StatePath -Force -ErrorAction SilentlyContinue } else { # Detect concurrent use of the wizard state file. - $otherPid = if ($prev.PSObject.Properties['ownerPid']) { [int]$prev.ownerPid } else { 0 } + $otherPid = if (Test-PspProp $prev 'ownerPid') { [int]$prev.ownerPid } else { 0 } $otherAlive = $false if ($otherPid -gt 0 -and $otherPid -ne $PID) { try { @@ -445,61 +780,52 @@ function Start-InstallWizard { 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 + Write-PspHost '' + Write-PspHost ("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 + Write-PspHost '' + Write-PspHost ("Found wizard state from {0}." -f (Get-PspProp $prev 'Timestamp')) -ForegroundColor Yellow if (Read-WizardYesNo -Prompt 'Resume?' -Default $true) { - foreach ($prop in $prev.Choices.PSObject.Properties) { - $choices[$prop.Name] = $prop.Value - } - # Restore ordered hashtables after the JSON round-trip. - 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 + $choices = Resolve-WizardState -Saved (Get-PspProp $prev 'Choices') + Write-PspHost ("Resuming from step after: {0}" -f ($choices.CompletedSteps -join ', ')) -ForegroundColor DarkGray } - else { Remove-Item $StatePath -Force -ErrorAction SilentlyContinue } + else { Remove-Item -LiteralPath $StatePath -Force -ErrorAction SilentlyContinue } } } catch { if ($_.Exception.Message -like 'WizardCancelled*') { throw } - Remove-Item $StatePath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $StatePath -Force -ErrorAction SilentlyContinue } } function Save-State { if (-not $StatePath) { return } $snap = @{ schemaVersion = $WIZARD_STATE_SCHEMA; Timestamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'); ownerPid = $PID; Choices = $choices } - $json = $snap | ConvertTo-Json -Depth 20 - [System.IO.File]::WriteAllText($StatePath, $json, [System.Text.UTF8Encoding]::new($false)) + $stateDir = Split-Path -Parent $StatePath + if ($stateDir -and -not (Test-Path -LiteralPath $stateDir)) { New-Item -ItemType Directory -Path $stateDir -Force | Out-Null } + Write-Utf8FileAtomic -Path $StatePath -Content ($snap | ConvertTo-Json -Depth 20) } - Write-Host '' - Write-Host '=========================================' -ForegroundColor Magenta - Write-Host ' PowerShellPerfect Install Wizard' -ForegroundColor Magenta - Write-Host '=========================================' -ForegroundColor Magenta - Write-Host ' Pick your cosmetics. All 145+ commands and extensibility APIs ship regardless.' -ForegroundColor DarkGray - Write-Host ' Press Enter at any prompt to accept default / skip that step.' -ForegroundColor DarkGray + Write-PspHost '' + Write-PspHost '=========================================' -ForegroundColor Magenta + Write-PspHost ' PowerShellPerfect Install Wizard' -ForegroundColor Magenta + Write-PspHost '=========================================' -ForegroundColor Magenta + Write-PspHost ' Pick your cosmetics. All 145+ commands and extensibility APIs ship regardless.' -ForegroundColor DarkGray + Write-PspHost ' Press Enter at any prompt to accept default / skip that step.' -ForegroundColor DarkGray # STEP 0: Offer the default preset before the full wizard on new runs. 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 + Write-PspHost '' + Write-PspHost '-- Quick start --' -ForegroundColor Cyan + Write-PspHost ' Preset: Tokyo Night scheme, CascadiaCode Nerd Font, your first installed editor,' -ForegroundColor DarkGray + Write-PspHost ' 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' } + $cascadia = $script:CuratedFonts | Where-Object { $_.Asset -eq 'CascadiaCode' } | Select-Object -First 1 + $choices.Theme = @{ Name = 'atomic'; Url = (Get-OmpThemeUrl -Name 'atomic'); Sha256 = $script:PinnedOmpThemes['atomic'] } if ($tokyoNight) { $choices.Scheme = $tokyoNight.Scheme } if ($cascadia) { $choices.Font = $cascadia } $choices.TabBar = if ($tokyoNight) { $tokyoNight.Scheme.background } else { '#1a1b26' } @@ -507,26 +833,28 @@ function Start-InstallWizard { $choices.Terminal = $null # keep terminal-config.json defaults $choices.PSReadLine = 'scheme' $choices.Background = $null - $choices.Editor = 'code' + # The first editor that is already installed, so the preset never installs one (Notepad is always there). + $installedEditor = $EditorCandidates | Where-Object { Get-Command $_.Cmd -ErrorAction Ignore } | Select-Object -First 1 + $choices.Editor = if ($installedEditor) { $installedEditor.Cmd } else { 'notepad' } $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 + Write-PspHost ' 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 + Write-PspHost '' + Write-PspHost ("[1/10] Fetching the Oh My Posh {0} theme catalog..." -f $script:OmpThemesRef) -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' } + Write-PspHost ' (network failed or empty; keeping default "pure")' -ForegroundColor Yellow + $choices.Theme = @{ Name = 'pure'; Url = (Get-OmpThemeUrl -Name 'pure'); Sha256 = $script:PinnedOmpThemes['pure'] } } else { - Write-Host (" {0} themes available. Type number, partial name, or Enter for 'pure' default." -f $themes.Count) -ForegroundColor DarkGray + Write-PspHost (" {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 } } @@ -578,9 +906,9 @@ function Start-InstallWizard { # STEP 5: Collect optional terminal appearance overrides. 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 + Write-PspHost '' + Write-PspHost '-- Terminal appearance --' -ForegroundColor Cyan + Write-PspHost ' Enter to keep current default for each field.' -ForegroundColor DarkGray $term = [ordered]@{} $opRaw = Read-Host ' Opacity 1-100 (default 75)' @@ -598,7 +926,7 @@ function Start-InstallWizard { $term['fontSize'] = $fsInt } - Write-Host ' Cursor shape: 1=bar 2=block 3=vintage 4=emptyBox 5=filledBox 6=doubleUnderscore' -ForegroundColor DarkGray + Write-PspHost ' 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] } @@ -610,7 +938,7 @@ function Start-InstallWizard { $term['padding'] = "$padInt, $padInt, $padInt, $padInt" } - Write-Host ' Scrollbar: 1=visible 2=hidden 3=always' -ForegroundColor DarkGray + Write-PspHost ' 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] } @@ -642,18 +970,18 @@ function Start-InstallWizard { # STEP 7: background image if ('Background' -notin $choices.CompletedSteps) { - Write-Host '' - Write-Host '-- Background image (optional) --' -ForegroundColor Cyan + Write-PspHost '' + Write-PspHost '-- 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)) { + if ($bgPath -and (Test-Path -LiteralPath $bgPath -PathType Leaf)) { $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 } + $choices.Background = @{ Path = (Resolve-Path -LiteralPath $bgPath).ProviderPath; Opacity = $op } } - else { Write-Host ' (no file found; skipping)' -ForegroundColor Yellow } + else { Write-PspHost ' (no file found; skipping)' -ForegroundColor Yellow } } $choices.CompletedSteps += 'Background' Save-State @@ -661,8 +989,8 @@ function Start-InstallWizard { # STEP 8: Editor preference if ('Editor' -notin $choices.CompletedSteps) { - Write-Host '' - Write-Host '-- Preferred editor --' -ForegroundColor Cyan + Write-PspHost '' + Write-PspHost '-- Preferred editor --' -ForegroundColor Cyan $choices.Editor = Select-PreferredEditor $choices.CompletedSteps += 'Editor' Save-State @@ -670,14 +998,15 @@ function Start-InstallWizard { # STEP 9: Offer telemetry opt-out when it is not already configured. 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 + Write-PspHost '' + Write-PspHost '-- PowerShell telemetry --' -ForegroundColor Cyan + if (Test-TelemetryOptOutSet) { + Write-PspHost ' 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 + $scopeLabel = if ($script:IsElevated) { 'machine-wide' } else { 'for your user account' } + $choices.TelemetryOptOut = Read-WizardYesNo -Prompt " Opt out of PowerShell telemetry? Sets POWERSHELL_TELEMETRY_OPTOUT=true $scopeLabel" -Default $false } $choices.CompletedSteps += 'Telemetry' Save-State @@ -685,13 +1014,13 @@ function Start-InstallWizard { # STEP 10: feature toggles if ('Features' -notin $choices.CompletedSteps) { - Write-Host '' - Write-Host '-- Profile feature toggles --' -ForegroundColor Cyan + Write-PspHost '' + Write-PspHost '-- 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['perDirProfiles'] = Read-WizardYesNo -Prompt ' Auto-load approved .psprc.ps1 files 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' @@ -699,549 +1028,337 @@ function Start-InstallWizard { } # 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:' + Write-PspHost '' + Write-PspHost '=========================================' -ForegroundColor Magenta + Write-PspHost ' Summary of your choices' -ForegroundColor Magenta + Write-PspHost '=========================================' -ForegroundColor Magenta + Write-PspHost (" OMP theme: {0}" -f $(if ($choices.Theme) { $choices.Theme.Name } else { '(default)' })) + Write-PspHost (" Color scheme: {0}" -f $(if ($choices.Scheme) { $choices.Scheme.name } else { '(default)' })) + Write-PspHost (" Nerd Font: {0}" -f $(if ($choices.Font) { $choices.Font.DisplayName } else { '(default)' })) + Write-PspHost (" Tab bar: {0}" -f $(if ($choices.TabBar) { "$($choices.TabBar) ($($choices.AppTheme) chrome)" } else { '(WT default)' })) + Write-PspHost ' Terminal:' if ($choices.Terminal) { - foreach ($k in $choices.Terminal.Keys) { - Write-Host (" {0,-16} = {1}" -f $k, $choices.Terminal[$k]) + foreach ($k in @($choices.Terminal.Keys)) { + Write-PspHost (" {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:' + else { Write-PspHost ' (all defaults)' -ForegroundColor DarkGray } + Write-PspHost (" PSReadLine: {0}" -f $(if ($choices.PSReadLine) { $choices.PSReadLine } else { '(theme.json default)' })) + Write-PspHost (" Background: {0}" -f $(if ($choices.Background) { "$($choices.Background.Path) @ $($choices.Background.Opacity)" } else { '(none)' })) + Write-PspHost (" Editor: {0}" -f $(if ($choices.Editor) { $choices.Editor } else { '(prompted outside wizard)' })) + Write-PspHost (" Telemetry: {0}" -f $(if ($choices.TelemetryOptOut) { 'opt out' } else { 'keep (no change)' })) + Write-PspHost ' Features:' if ($choices.Features) { - foreach ($k in $choices.Features.Keys) { - Write-Host (" {0,-18} = {1}" -f $k, $choices.Features[$k]) + foreach ($k in @($choices.Features.Keys)) { + Write-PspHost (" {0,-18} = {1}" -f $k, $choices.Features[$k]) } } - Write-Host '' + Write-PspHost '' 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 + Write-PspHost '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 } + # The caller removes the state file once the choices are saved, so a failed save can be resumed. 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 - -# Elevate local installs while allowing non-admin automation to skip privileged steps. -if (-not $isElevated -and -not $isCiHost) { - Write-Host "Please run this script as an Administrator!" -ForegroundColor Red - 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 -} - -# For remote installs, verify/download the profile bundle before any local mutation. -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 } - } -} - -# Report restrictive execution policies without changing them. -$currentUserPolicy = Get-ExecutionPolicy -Scope CurrentUser -if ($currentUserPolicy -eq 'AllSigned') { - $canPromptPolicy = [Environment]::UserInteractive -and -not $isCiHost -and -not [bool]$env:AI_AGENT - if ($canPromptPolicy) { try { $null = [Console]::KeyAvailable } catch { $canPromptPolicy = $false } } - if ($canPromptPolicy) { - Write-Host "CurrentUser execution policy is 'AllSigned' (stricter than RemoteSigned)." -ForegroundColor Yellow - $reply = Read-Host " Downgrade to RemoteSigned so the profile can load unsigned scripts? [y/N]" - if ($reply -match '^[Yy]') { - Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force - Write-Host "Execution policy set to RemoteSigned for CurrentUser." -ForegroundColor Green - } - else { - Write-Host " Kept AllSigned. Profile will not load unless all .ps1 files are signed." -ForegroundColor Yellow - } - } - else { - Write-Host " CurrentUser policy is AllSigned. Skipping downgrade prompt (non-interactive)." -ForegroundColor Yellow - } -} -elseif ($currentUserPolicy -in @('Restricted', 'Undefined')) { - Write-Host "CurrentUser execution policy is '$currentUserPolicy'." -ForegroundColor Yellow - Write-Host " If the installed profile is blocked, opt in manually with:" -ForegroundColor DarkYellow - Write-Host " Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" -ForegroundColor DarkYellow -} -# Offer LocalMachine execution policy guidance only in eligible interactive sessions. -if (-not $isCiHost) { - $machinePolicy = Get-ExecutionPolicy -Scope LocalMachine - if ($machinePolicy -in @('Restricted', 'AllSigned', 'Undefined')) { - 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 - } -} - -# Function to test internet connectivity (HTTPS - works through corporate proxies/firewalls) -function Test-InternetConnection { - try { - $response = Invoke-WebRequest -Uri "https://github.com" -Method Head -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop - return $response.StatusCode -eq 200 - } - catch { - Write-Host "Internet connection is required but not available (cannot reach github.com)." -ForegroundColor Red - return $false - } -} - -# Function to install Nerd Fonts +# Install a Nerd Font release asset for the current user (machine-wide when elevated), rejecting an archive whose hash differs. function Install-NerdFonts { + [CmdletBinding(SupportsShouldProcess = $true)] param ( - [string]$FontName = "CascadiaCode", - [string]$FontDisplayName = "CaskaydiaCove NF", - [string]$Version = "3.2.1" + [string]$FontName = 'CascadiaCode', + [string]$FontDisplayName = 'CaskaydiaCove NF', + [string]$Version = $script:NerdFontVersion, + # SHA-256 of .zip from the release's SHA-256.txt. + [string]$Sha256 ) $tempRoot = $null try { + if ($FontName -notmatch '^[A-Za-z0-9]+$' -or $Version -notmatch '^\d+\.\d+\.\d+$') { throw "Invalid font asset '$FontName' or version '$Version'." } [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Drawing") $fontCollection = New-Object System.Drawing.Text.InstalledFontCollection $fontFamilies = $fontCollection.Families.Name $fontCollection.Dispose() - if ($fontFamilies -notcontains "${FontDisplayName}") { - Write-Host " Installing ${FontDisplayName}..." -ForegroundColor Yellow - $fontZipUrl = "https://github.com/ryanoasis/nerd-fonts/releases/download/v${Version}/${FontName}.zip" - $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("psp-font-" + [System.IO.Path]::GetRandomFileName()) - $zipFilePath = Join-Path $tempRoot "${FontName}.zip" - $extractPath = Join-Path $tempRoot "extract" - - 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" - } + if ($fontFamilies -contains "${FontDisplayName}") { + Write-PspHost " ${FontDisplayName} already installed." -ForegroundColor Green + return $true + } + $scopeLabel = if ($script:IsElevated) { 'all users' } else { 'the current user' } + if (-not $PSCmdlet.ShouldProcess("$FontDisplayName (Nerd Fonts v$Version)", "Download and install the font for $scopeLabel")) { return $true } + Write-PspHost " Installing ${FontDisplayName} v${Version}..." -ForegroundColor Yellow + $fontZipUrl = "https://github.com/ryanoasis/nerd-fonts/releases/download/v${Version}/${FontName}.zip" + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("psp-font-" + [System.IO.Path]::GetRandomFileName()) + $zipFilePath = Join-Path $tempRoot "${FontName}.zip" + $extractPath = Join-Path $tempRoot "extract" - Expand-Archive -Path $zipFilePath -DestinationPath $extractPath -Force - $destination = (New-Object -ComObject Shell.Application).Namespace(0x14) - # A shell font install lands machine-wide when elevated and per-user otherwise, so - # presence must be checked in both stores - watching only the system one makes the - # wait loop below time out and report failure after a perfectly good install. - $fontStores = @( - (Join-Path $env:SystemRoot 'Fonts') - (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts') - ) - $fontFiles = Get-ChildItem -Path $extractPath -Recurse -Filter "*.ttf" - $copied = 0 - foreach ($f in $fontFiles) { - $fontName = $f.Name - if (-not @($fontStores | Where-Object { Test-Path -LiteralPath (Join-Path $_ $fontName) })) { - $destination.CopyHere($f.FullName, 0x10) - $copied++ - } + New-Item -ItemType Directory -Path $extractPath -Force | Out-Null + Invoke-DownloadWithRetry -Uri $fontZipUrl -OutFile $zipFilePath -TimeoutSec 60 -MaxAttempts 2 + $zipHash = (Get-FileHash -LiteralPath $zipFilePath -Algorithm SHA256).Hash + if ($Sha256) { + if ($zipHash -ne $Sha256.ToUpperInvariant()) { throw "Font archive hash mismatch (expected $Sha256, got $zipHash)" } + } + else { + Write-PspHost " No pinned hash for ${FontName}.zip v${Version}; archive SHA-256 $zipHash (not verified)." -ForegroundColor Yellow + } + + Expand-Archive -LiteralPath $zipFilePath -DestinationPath $extractPath -Force + $destination = (New-Object -ComObject Shell.Application).Namespace(0x14) + # A shell font install lands machine-wide when elevated and per-user otherwise, so + # presence must be checked in both stores - watching only the system one makes the + # wait loop below time out and report failure after a perfectly good install. + $fontStores = @( + (Join-Path $env:SystemRoot 'Fonts') + (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts') + ) + $fontFiles = Get-ChildItem -LiteralPath $extractPath -Recurse -Filter "*.ttf" + $copied = 0 + foreach ($f in $fontFiles) { + $fontFileName = $f.Name + if (-not @($fontStores | Where-Object { Test-Path -LiteralPath (Join-Path $_ $fontFileName) })) { + $destination.CopyHere($f.FullName, 0x10) + $copied++ } - # CopyHere is async - wait for fonts to arrive before deleting source - $pending = $null - if ($copied -gt 0) { - $timeout = 60; $elapsed = 0 - while ($elapsed -lt $timeout) { - $pending = $fontFiles | Where-Object { - $fontName = $_.Name - -not @($fontStores | Where-Object { Test-Path -LiteralPath (Join-Path $_ $fontName) }) - } - if (-not $pending) { break } - Start-Sleep -Milliseconds 500 - $elapsed += 0.5 + } + # CopyHere is async - wait for fonts to arrive before deleting source + $pending = $null + if ($copied -gt 0) { + $timeout = 60; $elapsed = 0 + while ($elapsed -lt $timeout) { + $pending = $fontFiles | Where-Object { + $fontFileName = $_.Name + -not @($fontStores | Where-Object { Test-Path -LiteralPath (Join-Path $_ $fontFileName) }) } + if (-not $pending) { break } + Start-Sleep -Milliseconds 500 + $elapsed += 0.5 } - - Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' - $tempRoot = $null - if ($copied -gt 0 -and $pending) { - # Report a partial font installation when files do not appear before timeout. - 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 } - else { - Write-Host " ${FontDisplayName} already installed." -ForegroundColor Green - return $true + + Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' + $tempRoot = $null + if ($copied -gt 0 -and $pending) { + # Report a partial font installation when files do not appear before timeout. + Write-PspHost " Font copy timed out: $(@($pending).Count) of $copied file(s) did not install." -ForegroundColor Red + return $false } + Write-PspHost " ${FontDisplayName} installed." -ForegroundColor Green + return $true } catch { Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' - Write-Host " Failed to install ${FontDisplayName}: $_" -ForegroundColor Red + Write-PspHost " Failed to install ${FontDisplayName}: $_" -ForegroundColor Red return $false } } -# Return all Windows Terminal settings files using the same implementation as the 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 external command aliases using the same implementation as the profile. -function Get-ExternalCommandPath { - param( +# Download an Oh My Posh theme into the cache folder, verifying its SHA-256 when one is known. +function Install-OhMyPoshTheme { + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory)] + [string]$ThemeName, [Parameter(Mandatory)] - [string]$CommandName + [string]$ThemeUrl, + [string]$Sha256 ) - - $cmd = Get-Command $CommandName -ErrorAction Ignore - if (-not $cmd) { return $null } - - if ($cmd.CommandType -eq 'Alias' -and $cmd.Definition -and $cmd.Definition -ne $CommandName) { - return Get-ExternalCommandPath -CommandName $cmd.Definition + if ($ThemeName -notmatch '^[A-Za-z0-9._-]+$') { + Write-PspHost " Refusing theme with an invalid name: $ThemeName" -ForegroundColor Red + return $false } - - $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 + $cacheDir = Get-PspCacheDir + $themeFilePath = Join-Path $cacheDir "$ThemeName.omp.json" + $tempThemePath = $null + try { + if (-not (Test-PspTrustedThemeUrl $ThemeUrl)) { + throw "Refusing to download theme from untrusted host: $ThemeUrl" + } + $alreadyValid = $false + if (Test-Path -LiteralPath $themeFilePath -PathType Leaf) { + try { + if ($null -ne (Read-JsonFile $themeFilePath)) { + $alreadyValid = -not $Sha256 -or ((Get-FileHash -LiteralPath $themeFilePath -Algorithm SHA256).Hash -eq $Sha256.ToUpperInvariant()) + } + } + catch { $null = $_ } + } + if ($alreadyValid) { + Write-PspHost " Theme '$ThemeName' already present." -ForegroundColor Green + return $true } + if (-not $PSCmdlet.ShouldProcess($themeFilePath, "Download Oh My Posh theme '$ThemeName'")) { return $true } + if (-not (Test-Path -LiteralPath $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } + $tempThemePath = Join-Path $cacheDir ("{0}.{1}.download" -f $ThemeName, [System.IO.Path]::GetRandomFileName()) + Invoke-DownloadWithRetry -Uri $ThemeUrl -OutFile $tempThemePath + if ($null -eq (Read-JsonFile $tempThemePath)) { throw 'Downloaded theme file is empty' } + if ($Sha256) { + $actual = (Get-FileHash -LiteralPath $tempThemePath -Algorithm SHA256).Hash + if ($actual -ne $Sha256.ToUpperInvariant()) { throw "Theme hash mismatch (expected $Sha256, got $actual)" } + } + Move-Item -LiteralPath $tempThemePath -Destination $themeFilePath -Force + $tempThemePath = $null + Write-PspHost " Theme '$ThemeName' downloaded." -ForegroundColor Green + return $true + } + catch { + Write-PspHost " Failed to download/validate theme: $_" -ForegroundColor Red + return $false + } + finally { + if ($tempThemePath) { Remove-Item -LiteralPath $tempThemePath -Force -ErrorAction SilentlyContinue } } - - 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 = @( - (Join-Path $env:LOCALAPPDATA 'Programs\oh-my-posh\bin\oh-my-posh.exe'), - (Join-Path $env:LOCALAPPDATA 'Programs\oh-my-posh\oh-my-posh.exe'), - (Join-Path $env:ProgramFiles 'oh-my-posh\bin\oh-my-posh.exe') +# Install or verify a winget package; winget accepts the source and package agreements for the user. +function Install-WingetPackage { + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory)] + [string]$Name, + [Parameter(Mandatory)] + [string]$Id ) - - $pf86 = [System.Environment]::GetEnvironmentVariable('ProgramFiles(x86)', 'Process') - if ($pf86) { - $candidatePaths += (Join-Path $pf86 'oh-my-posh\bin\oh-my-posh.exe') + $winget = Get-Command winget -CommandType Application -ErrorAction Ignore | Select-Object -First 1 + if (-not $winget) { + Write-PspHost " winget not found. Skipping $Name." -ForegroundColor Yellow + return $false } - - $resolvedPath = Get-ExternalCommandPath -CommandName 'oh-my-posh' - $windowsAppsRoot = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' - if ($resolvedPath -and $resolvedPath -notlike "$windowsAppsRoot*") { - return $resolvedPath + if (-not $PSCmdlet.ShouldProcess($Name, "Install with winget ($Id), accepting the winget source and package agreements")) { return $true } + $result = Invoke-SetupNative -FilePath $winget.Source -ArgumentList @('install', '-e', '--id', $Id, '--accept-source-agreements', '--accept-package-agreements') + if ($result.ExitCode -eq 0) { + Write-PspHost " $Name installed." -ForegroundColor Green + return $true } - - foreach ($candidatePath in ($candidatePaths | Select-Object -Unique)) { - if (-not (Test-Path -LiteralPath $candidatePath)) { continue } - - $candidateDir = Split-Path -Path $candidatePath -Parent - $pathEntries = @($env:PATH -split ';' | Where-Object { $_ }) - if ($pathEntries -notcontains $candidateDir) { - $env:PATH = (@($candidateDir, $env:PATH) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' - } - - return $candidatePath - } - - # Last resort: the WindowsApps app-execution alias. MSIX/Store installs of oh-my-posh - # expose the CLI only through this shim - it is a 0-byte reparse point, so it is - # preferred last, but it launches normally and is the only entry point available. - if ($resolvedPath) { return $resolvedPath } - - return $null -} - -# Check for internet connectivity before proceeding (skip when using a local repo or a verified remote bundle) -if (-not $LocalRepo -and -not $script:VerifiedInstallBundle -and -not (Test-InternetConnection)) { - if ($PSCommandPath) { exit 1 } else { return } -} - -# JSONC comment-stripping regex. -$_q = [char]34 -$jsoncCommentPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*`$" - -# Download theme.json (single source of truth for theme + WT metadata) -$profileConfig = $null -$configCachePath = Join-Path $env:LOCALAPPDATA "PowerShellProfile" -if (!(Test-Path -Path $configCachePath)) { - New-Item -Path $configCachePath -ItemType "directory" -Force | Out-Null -} - -# Define editor choices and optional winget package IDs. -# Defined before the wizard runs: PowerShell only registers a function once its -# definition statement executes, so anything Start-InstallWizard calls must appear above it. -$EditorCandidates = @( - @{ Cmd = 'code'; Display = 'Visual Studio Code'; WingetId = 'Microsoft.VisualStudioCode' } - @{ Cmd = 'nvim'; Display = 'Neovim'; WingetId = 'Neovim.Neovim' } - @{ Cmd = 'vim'; Display = 'Vim'; WingetId = 'vim.vim' } - @{ Cmd = 'msedit'; Display = 'Microsoft Edit'; WingetId = 'Microsoft.Edit' } - @{ Cmd = 'subl'; Display = 'Sublime Text'; WingetId = 'SublimeHQ.SublimeText.4' } - @{ Cmd = 'notepad++'; Display = 'Notepad++'; WingetId = 'Notepad++.Notepad++' } - @{ Cmd = 'notepad'; Display = 'Notepad (always available)'; WingetId = $null } -) - -# Interactive editor preference prompt - returns chosen Cmd string -function Select-PreferredEditor { - $defaultChoice = $null - Write-Host "" - Write-Host " Select your preferred code editor:" -ForegroundColor Cyan - Write-Host "" - for ($i = 0; $i -lt $EditorCandidates.Count; $i++) { - $ed = $EditorCandidates[$i] - $num = $i + 1 - $installed = [bool](Get-Command $ed.Cmd -ErrorAction Ignore) - if ($installed) { - if ($null -eq $defaultChoice) { $defaultChoice = $i } - Write-Host " $num) $($ed.Display) ($($ed.Cmd)) " -NoNewline -ForegroundColor White - Write-Host '[installed]' -ForegroundColor Green - } - else { - Write-Host " $num) $($ed.Display) ($($ed.Cmd))" -ForegroundColor DarkGray - } - } - if ($null -eq $defaultChoice) { $defaultChoice = 0 } - $defaultNum = $defaultChoice + 1 - Write-Host "" - $reply = Read-Host " Choice [$defaultNum]" - if ([string]::IsNullOrWhiteSpace($reply)) { - return $EditorCandidates[$defaultChoice].Cmd - } - $parsed = 0 - $chosen = $null - if ([int]::TryParse($reply, [ref]$parsed) -and $parsed -ge 1 -and $parsed -le $EditorCandidates.Count) { - $chosen = $EditorCandidates[$parsed - 1] + elseif ($result.ExitCode -eq -1978335185 -or $result.ExitCode -eq -1978335189) { + # -1978335185 = already installed (winget install) + # -1978335189 = no applicable update (winget upgrade) + Write-PspHost " $Name already installed." -ForegroundColor Green + return $true } else { - Write-Host " Invalid choice, using default." -ForegroundColor Yellow - return $EditorCandidates[$defaultChoice].Cmd - } - if (-not (Get-Command $chosen.Cmd -ErrorAction Ignore)) { - Write-Host " '$($chosen.Cmd)' is not installed." -ForegroundColor Yellow - $confirm = Read-Host " Use anyway? [y/N]" - if ($confirm -notmatch '^[Yy]') { - Write-Host " Using default instead." -ForegroundColor Yellow - return $EditorCandidates[$defaultChoice].Cmd - } + Write-PspHost " $Name install may have failed (exit code: $($result.ExitCode))" -ForegroundColor Red + return $false } - return $chosen.Cmd } -# Run the wizard before downstream installation steps consume user settings. -$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)) { - # Remove wizard state older than 24 hours unless resume was requested. - $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 - - # Install the selected Nerd Font and retain the default fallback on failure. - 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 - } - } - - # Apply the selected Oh My Posh theme URL to the fetched profile configuration. - $script:WizardOmpTheme = $wizChoices.Theme - - # Reuse wizard editor and telemetry choices in later setup steps. - $script:WizardEditor = $wizChoices.Editor - $script:WizardTelemetryHandled = ($null -ne $wizChoices.TelemetryOptOut) - - # Apply the telemetry choice and record ownership for uninstall. - 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 - } +# Return the editor to configure, installing the chosen one with winget when it is missing. +function Resolve-ConfiguredEditor { + [CmdletBinding(SupportsShouldProcess = $true)] + 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 Ignore)) { + if ($script:IsCiHost) { + Write-PspHost " CI mode: skipping editor install for $($chosen.Display)." -ForegroundColor DarkGray + $resolvedEditor = 'notepad' } - } - catch { - if ($_.Exception.Message -eq 'WizardCancelled') { - Write-Host 'Continuing setup without wizard overrides.' -ForegroundColor Yellow + elseif (Install-WingetPackage -Name $chosen.Display -Id $chosen.WingetId) { + # Exclude empty PATH entries that Windows could interpret as the current directory. + Update-SessionPathFromRegistry } else { - Write-Warning ("Wizard failed: {0}. Continuing with defaults." -f $_.Exception.Message) + Write-PspHost " Could not install $($chosen.Display). Using Notepad." -ForegroundColor Yellow + $resolvedEditor = 'notepad' } } + return $resolvedEditor } -try { - $configTmp = Join-Path $env:TEMP ("psp-theme-" + [System.IO.Path]::GetRandomFileName() + ".json") - Copy-Item (Resolve-SetupSourcePath -Kind 'theme') $configTmp -Force -ErrorAction Stop - $profileConfig = Get-Content $configTmp -Raw -Encoding UTF8 | ConvertFrom-Json - Copy-Item $configTmp (Join-Path $configCachePath "theme.json") -Force - Remove-Item $configTmp -ErrorAction SilentlyContinue -} -catch { - Write-Host "Could not load theme.json. Theme and color scheme steps will be skipped." -ForegroundColor Yellow -} - -# Download terminal-config.json (WT behavior settings: scrollbar, historySize, keybindings) -$terminalConfig = $null -try { - $terminalConfigTmp = Join-Path $env:TEMP ("psp-terminal-" + [System.IO.Path]::GetRandomFileName() + ".json") - Copy-Item (Resolve-SetupSourcePath -Kind 'terminal') $terminalConfigTmp -Force -ErrorAction Stop - $terminalConfig = Get-Content $terminalConfigTmp -Raw -Encoding UTF8 | ConvertFrom-Json - Copy-Item $terminalConfigTmp (Join-Path $configCachePath "terminal-config.json") -Force - Remove-Item $terminalConfigTmp -ErrorAction SilentlyContinue -} -catch { - Write-Host "Could not load terminal-config.json. Terminal behavior settings (font, scrollbar, keybindings) will not be applied." -ForegroundColor Yellow -} - -# Merge helper - deep-merges PSCustomObjects so nested keys are preserved -function Merge-JsonObject { +# Point the editor preference in each profile_user.ps1 at the chosen editor, keeping a .bak copy of the previous file. +function Set-PreferredEditorInProfiles { + [CmdletBinding(SupportsShouldProcess = $true)] param( - $base, - $override + [Parameter(Mandatory)][string]$EditorName, + [Parameter(Mandatory)][string]$CommentLabel, + [Parameter(Mandatory)][string[]]$ProfileDirs ) - - 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]) { - Merge-JsonObject $baseVal.Value $prop.Value + # The name ends up inside a single-quoted PowerShell string. + $editorLine = "`$script:EditorPriority = @('{0}', 'notepad')" -f ($EditorName -replace "'", "''") + foreach ($dir in $ProfileDirs) { + $userProfilePath = Join-Path $dir 'profile_user.ps1' + if (-not (Test-Path -LiteralPath $userProfilePath -PathType Leaf)) { continue } + $content = Get-Utf8FileText $userProfilePath + if ($content -match '(?m)^\$script:EditorPriority\s*=') { + $updated = [regex]::Replace($content, '(?m)^\$script:EditorPriority\s*=[^\r\n]*', $editorLine.Replace('$', '$$')) } else { - $base | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force + $updated = $content.TrimEnd() + "`r`n`r`n# --- Preferred editor ($CommentLabel) ---`r`n$editorLine`r`n" + } + if ($updated -ceq $content) { continue } + if ($PSCmdlet.ShouldProcess($userProfilePath, "Set the preferred editor to $EditorName")) { + Write-Utf8FileAtomic -Path $userProfilePath -Content $updated -BackupPath "$userProfilePath.bak" } } } -# Apply user-settings.json overrides (never downloaded, never overwritten) -$script:WtManaged = $true -$userSettingsPath = Join-Path $configCachePath "user-settings.json" -if (Test-Path $userSettingsPath) { +# Return $true when POWERSHELL_TELEMETRY_OPTOUT is already set for this user or machine-wide. +function Test-TelemetryOptOutSet { + [bool]([System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'Machine')) -or + [bool]([System.Environment]::GetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'User')) +} + +# Set POWERSHELL_TELEMETRY_OPTOUT (machine-wide when elevated, otherwise for this user) and record it so uninstall removes only what setup set. +function Set-TelemetryOptOut { + [CmdletBinding(SupportsShouldProcess = $true)] + param([Parameter(Mandatory)][string]$CacheDir) + $scope = if ($script:IsElevated) { 'Machine' } else { 'User' } + if (-not $PSCmdlet.ShouldProcess("POWERSHELL_TELEMETRY_OPTOUT ($scope)", 'Set to true')) { return } try { - $userSettings = Get-Content $userSettingsPath -Raw -Encoding UTF8 | ConvertFrom-Json - # windowsTerminal.manage = false hands settings.json to another tool, such as a dotfiles repo. - if ($userSettings.windowsTerminal -and $userSettings.windowsTerminal.PSObject.Properties['manage'] -and $userSettings.windowsTerminal.manage -eq $false) { $script:WtManaged = $false } - if ($profileConfig -and $userSettings.theme) { - if (-not $profileConfig.theme) { - $profileConfig | Add-Member -NotePropertyName "theme" -NotePropertyValue ([PSCustomObject]@{}) -Force - } - Merge-JsonObject $profileConfig.theme $userSettings.theme - } - if ($profileConfig -and $userSettings.windowsTerminal) { - if (-not $profileConfig.windowsTerminal) { - $profileConfig | Add-Member -NotePropertyName "windowsTerminal" -NotePropertyValue ([PSCustomObject]@{}) -Force - } - Merge-JsonObject $profileConfig.windowsTerminal $userSettings.windowsTerminal - } - if ($terminalConfig -and $userSettings.defaults) { - if (-not $terminalConfig.defaults) { - $terminalConfig | Add-Member -NotePropertyName "defaults" -NotePropertyValue ([PSCustomObject]@{}) -Force - } - Merge-JsonObject $terminalConfig.defaults $userSettings.defaults - } - if ($terminalConfig -and $userSettings.keybindings) { - if (-not $terminalConfig.keybindings) { - $terminalConfig | Add-Member -NotePropertyName "keybindings" -NotePropertyValue @() -Force - } - $terminalConfig.keybindings = @($terminalConfig.keybindings) + @($userSettings.keybindings) - } - Write-Host "User overrides applied from user-settings.json" -ForegroundColor DarkGray + [System.Environment]::SetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'true', $scope) + if (-not (Test-Path -LiteralPath $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } + Write-Utf8FileAtomic -Path (Join-Path $CacheDir 'telemetry.owned') -Content ("set by setup.ps1 at {0}`nscope: {1}`n" -f (Get-Date -Format o), $scope) + Write-PspHost " Telemetry opt-out applied ($($scope.ToLowerInvariant()) scope)." -ForegroundColor Green } catch { - Write-Host "Failed to parse user-settings.json: $_" -ForegroundColor Yellow + Write-PspHost " Failed to set POWERSHELL_TELEMETRY_OPTOUT: $_" -ForegroundColor Yellow } } -# Continue profile installation without optional winget-managed tools. -if (-not (Get-Command winget -ErrorAction Ignore)) { - 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 "" -Write-Host "PowerShell Profile Setup" -ForegroundColor Cyan -Write-Host "========================" -ForegroundColor Cyan -Write-Host "" - -# Profile creation or update (install for both PS5 and PS7) -Write-Host "[1/10] Profile" -ForegroundColor Cyan -# Derive Documents root from $PROFILE. -$docsRoot = Split-Path (Split-Path $PROFILE) -$profileDirs = @( - Join-Path $docsRoot "PowerShell" # PS7 (Core) - Join-Path $docsRoot "WindowsPowerShell" # PS5 (Desktop) -) -$profileInstalled = $true -foreach ($dir in $profileDirs) { - $targetProfile = Join-Path $dir "Microsoft.PowerShell_profile.ps1" - try { - if (!(Test-Path -Path $dir)) { - 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 ("psp-profile_download_" + (Split-Path $dir -Leaf) + "_" + [System.IO.Path]::GetRandomFileName() + ".ps1") - Copy-Item (Resolve-SetupSourcePath -Kind 'profile') $tempDownload -Force -ErrorAction Stop - if (Test-Path -Path $targetProfile -PathType Leaf) { - # Preserve the pre-install profile once as an immutable copy that rotation never deletes - # and Uninstall-Profile restores; oldprofile* rotation below never matches this name. - $originalBackup = Join-Path $dir 'profile.original.ps1' - if (-not (Test-Path -Path $originalBackup)) { - Copy-Item -Path $targetProfile -Destination $originalBackup -Force - Write-Host " Original profile saved to [$originalBackup]" -ForegroundColor DarkGray - } - # Keep the latest five timestamped profile backups. - $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 the profile into both edition folders with backups; returns $true when every copy succeeded. +function Install-ProfileFiles { + [CmdletBinding(SupportsShouldProcess = $true)] + param([Parameter(Mandatory)][string]$Source, [Parameter(Mandatory)][string[]]$ProfileDirs) + $allOk = $true + foreach ($dir in $ProfileDirs) { + $targetProfile = Join-Path $dir 'Microsoft.PowerShell_profile.ps1' + if (-not $PSCmdlet.ShouldProcess($targetProfile, 'Install the profile (the current one is backed up)')) { continue } + $staged = $null + try { + if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + # Stage next to the target first so a failed copy never leaves a partial profile. + $staged = Join-Path $dir ('.psp-new-' + [System.IO.Path]::GetRandomFileName() + '.ps1') + Copy-Item -LiteralPath $Source -Destination $staged -Force + if (Test-Path -LiteralPath $targetProfile -PathType Leaf) { + # Preserve the pre-install profile once as an immutable copy that rotation never deletes + # and Uninstall-Profile restores; oldprofile* rotation below never matches this name. + $originalBackup = Join-Path $dir 'profile.original.ps1' + # Earlier versions could save an installed copy of this profile as the original; such a copy is replaced. + $originalIsOurs = (Test-Path -LiteralPath $originalBackup) -and (Test-PspProfileFile -Path $originalBackup) + if ($originalIsOurs -or -not (Test-Path -LiteralPath $originalBackup)) { + # The current profile, or when that is this profile already, the oldest backup that is not. + $originalSource = Get-PspOriginalProfileSource -Directory $dir + if ($originalSource) { + Copy-Item -LiteralPath $originalSource -Destination $originalBackup -Force + Write-PspHost " Original profile saved to [$originalBackup]" -ForegroundColor DarkGray + } + elseif ($originalIsOurs) { Remove-Item -LiteralPath $originalBackup -Force } + } + # Keep the latest five timestamped profile backups; a failed backup stops this copy. + $backupPath = Join-Path $dir ('oldprofile.{0}.ps1' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + Copy-Item -LiteralPath $targetProfile -Destination $backupPath -Force + Write-PspHost " Backup saved to [$backupPath]" -ForegroundColor DarkGray + Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } } - } - Move-Item -Path $tempDownload -Destination $targetProfile -Force - Write-Host " Profile installed at [$targetProfile]" -ForegroundColor Green - - # Create starter user override file if it doesn't exist (never overwrite) - $userProfilePath = Join-Path $dir "profile_user.ps1" - if (-not (Test-Path $userProfilePath)) { - $userProfileContent = @' + Move-Item -LiteralPath $staged -Destination $targetProfile -Force + $staged = $null + Write-PspHost " Profile installed at [$targetProfile]" -ForegroundColor Green + + # Create starter user override file if it doesn't exist (never overwrite) + $userProfilePath = Join-Path $dir 'profile_user.ps1' + if (-not (Test-Path -LiteralPath $userProfilePath)) { + $userProfileContent = @' ### Persistent personal overrides loaded by the main profile. # --- Preferred editor (used by the edit command) --- @@ -1259,556 +1376,515 @@ foreach ($dir in $profileDirs) { # --- Import additional modules --- # Import-Module posh-git '@ - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($userProfilePath, $userProfileContent, $utf8NoBom) - Write-Host " User override file created at [$userProfilePath]" -ForegroundColor Green + Write-Utf8FileAtomic -Path $userProfilePath -Content $userProfileContent + Write-PspHost " User override file created at [$userProfilePath]" -ForegroundColor Green + } + else { + Write-PspHost " User override file already exists at [$userProfilePath] (preserved)" -ForegroundColor DarkGray + } } - else { - Write-Host " User override file already exists at [$userProfilePath] (preserved)" -ForegroundColor DarkGray + catch { + Write-PspHost " Failed to install profile at [$targetProfile]: $_" -ForegroundColor Red + $allOk = $false + } + finally { + if ($staged) { Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue } } } - catch { - Write-Host " Failed to install profile at [$targetProfile]: $_" -ForegroundColor Red - Remove-Item $tempDownload -ErrorAction SilentlyContinue - $profileInstalled = $false - } + return $allOk } -# Create starter user-settings.json template if it doesn't exist (never overwrite) -$userSettingsTemplate = Join-Path $configCachePath "user-settings.json" -if (-not (Test-Path $userSettingsTemplate)) { - $settingsContent = @' -{ - "_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": { "manage": true, "colorScheme": "One Half Dark", "cursorColor": "#ffffff" }, - "_manage_note": "set windowsTerminal.manage to false when another tool (for example a dotfiles repo) owns Windows Terminal settings.json; the profile then leaves that file alone", - "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": [] +# Run every install step after the bundle is verified; returns $true when the profile was installed. +function Invoke-SetupInstall { + [CmdletBinding(SupportsShouldProcess = $true)] + param([Parameter(Mandatory)][object]$Bundle) + + $configCachePath = Get-PspCacheDir + if ($script:IsElevated) { + Write-PspHost 'Running elevated is not required: fonts and the telemetry opt-out apply machine-wide, and the terminal is not restarted at the end.' -ForegroundColor DarkYellow } -} -'@ - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($userSettingsTemplate, $settingsContent, $utf8NoBom) - Write-Host " User settings template created at [$userSettingsTemplate]" -ForegroundColor Green -} -else { - Write-Host " User settings file already exists at [$userSettingsTemplate] (preserved)" -ForegroundColor DarkGray -} -function Set-PreferredEditorInProfiles { - param( - [Parameter(Mandatory)][string]$EditorName, - [Parameter(Mandatory)][string]$CommentLabel - ) - $editorLine = '$script:EditorPriority = @(' + "'$EditorName', 'notepad'" + ')' - foreach ($dir in $profileDirs) { - $userProfilePath = Join-Path $dir "profile_user.ps1" - if (-not (Test-Path $userProfilePath)) { continue } - $content = [System.IO.File]::ReadAllText($userProfilePath) - if ($content -match '(?m)^\$script:EditorPriority\s*=') { - $content = $content -replace '(?m)^\$script:EditorPriority\s*=.*$', $editorLine + # Report restrictive execution policies; only the CurrentUser scope is ever changed, and only with consent. + $currentUserPolicy = Get-ExecutionPolicy -Scope CurrentUser + if ($currentUserPolicy -eq 'AllSigned') { + if ($script:CanPrompt -and -not $WhatIfPreference) { + Write-PspHost "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]' -and $PSCmdlet.ShouldProcess('CurrentUser execution policy', 'Set to RemoteSigned')) { + Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force + Write-PspHost "Execution policy set to RemoteSigned for CurrentUser." -ForegroundColor Green + } + else { + Write-PspHost " Kept AllSigned. Profile will not load unless all .ps1 files are signed." -ForegroundColor Yellow + } } else { - $content = $content.TrimEnd() + "`r`n`r`n# --- Preferred editor ($CommentLabel) ---`r`n$editorLine`r`n" + Write-PspHost " CurrentUser policy is AllSigned. Skipping downgrade prompt (non-interactive)." -ForegroundColor Yellow + } + } + elseif ($currentUserPolicy -in @('Restricted', 'Undefined')) { + Write-PspHost "CurrentUser execution policy is '$currentUserPolicy'." -ForegroundColor Yellow + Write-PspHost " If the installed profile is blocked, opt in manually with:" -ForegroundColor DarkYellow + Write-PspHost " Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" -ForegroundColor DarkYellow + } + if (-not $script:IsCiHost) { + $machinePolicy = Get-ExecutionPolicy -Scope LocalMachine + if ($machinePolicy -in @('Restricted', 'AllSigned', 'Undefined')) { + Write-PspHost "LocalMachine execution policy is '$machinePolicy'." -ForegroundColor Yellow + Write-PspHost " setup.ps1 leaves machine-wide policy unchanged. Change it manually only if you intend to affect all users." -ForegroundColor DarkYellow } - $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText($userProfilePath, $content, $utf8NoBom) } -} -if ($script:DownloadedProfilePath) { Remove-Item $script:DownloadedProfilePath -Force -ErrorAction SilentlyContinue } -if ($script:DownloadedThemeConfigPath) { Remove-Item $script:DownloadedThemeConfigPath -Force -ErrorAction SilentlyContinue } -if ($script:DownloadedTerminalConfigPath) { Remove-Item $script:DownloadedTerminalConfigPath -Force -ErrorAction SilentlyContinue } + if (-not (Test-Path -LiteralPath $configCachePath) -and $PSCmdlet.ShouldProcess($configCachePath, 'Create the profile cache folder')) { + New-Item -ItemType Directory -Path $configCachePath -Force | Out-Null + } + $userSettingsPath = Join-Path $configCachePath 'user-settings.json' -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 Ignore)) { - if ($isCiHost) { - Write-Host " CI mode: skipping editor install for $($chosen.Display)." -ForegroundColor DarkGray - $resolvedEditor = 'notepad' + # Run the wizard before downstream installation steps consume user settings. + $wantWizard = ($Wizard -or $script:CanPrompt) -and -not $SkipWizard -and -not $WhatIfPreference + if ($wantWizard) { + $wizardState = Join-Path $configCachePath 'wizard-state.json' + if (-not $Resume -and (Test-Path -LiteralPath $wizardState)) { + # Remove wizard state older than 24 hours unless resume was requested. + $age = (Get-Date) - (Get-Item -LiteralPath $wizardState).LastWriteTime + if ($age.TotalHours -gt 24) { Remove-Item -LiteralPath $wizardState -Force -ErrorAction SilentlyContinue } + } + try { + $wizChoices = Start-InstallWizard -StatePath $wizardState + Save-WizardChoices -Choices $wizChoices -UserSettingsPath $userSettingsPath + Remove-Item -LiteralPath $wizardState -Force -ErrorAction SilentlyContinue + Write-PspHost '' + Write-PspHost 'Wizard choices saved to user-settings.json.' -ForegroundColor Green + + # Install the selected Nerd Font and retain the default fallback on failure. + if ($wizChoices.Font -and -not $script:IsCiHost) { + Write-PspHost ("Installing Nerd Font: {0} v{1}..." -f $wizChoices.Font.DisplayName, $script:NerdFontVersion) -ForegroundColor Cyan + $script:WizardFontInstalled = Install-NerdFonts -FontName $wizChoices.Font.Asset -FontDisplayName $wizChoices.Font.DisplayName -Version $script:NerdFontVersion -Sha256 $wizChoices.Font.Sha256 + if (-not $script:WizardFontInstalled) { + Write-PspHost " Wizard font install did not complete; step [4/10] will retry with the default font." -ForegroundColor Yellow + } + } + + # Reuse wizard theme, editor and telemetry choices in later setup steps. + $script:WizardOmpTheme = $wizChoices.Theme + $script:WizardEditor = $wizChoices.Editor + $script:WizardTelemetryHandled = ($null -ne $wizChoices.TelemetryOptOut) + if ($wizChoices.TelemetryOptOut -and -not (Test-TelemetryOptOutSet)) { Set-TelemetryOptOut -CacheDir $configCachePath } } - elseif (Get-Command winget -ErrorAction Ignore) { - 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) { - # Exclude empty PATH entries that Windows could interpret as the current directory. - Update-SessionPathFromRegistry - Write-Host " $($chosen.Display) installed." -ForegroundColor Green + catch { + if ($_.Exception.Message -eq 'WizardCancelled') { + Write-PspHost 'Continuing setup without wizard overrides.' -ForegroundColor Yellow } else { - Write-Host " Could not install $($chosen.Display) via winget. Using Notepad." -ForegroundColor Yellow - $resolvedEditor = 'notepad' + Write-Warning ("Wizard failed: {0}. Continuing with defaults; run setup.ps1 -Wizard -Resume to finish it." -f $_.Exception.Message) } } - else { - Write-Host " winget not found. Using Notepad." -ForegroundColor Yellow - $resolvedEditor = 'notepad' - } } - return $resolvedEditor -} -# Use the wizard's editor choice or collect one interactively. -Write-Host "[2/10] Editor preference" -ForegroundColor Cyan -$canPromptEditor = [Environment]::UserInteractive -and -not $isCiHost -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 - 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 -} + # theme.json (theme + WT metadata) and terminal-config.json (WT behavior) from the verified bundle. + $profileConfig = $null + $terminalConfig = $null + try { $profileConfig = Read-JsonFile $Bundle.Theme } + catch { Write-PspHost "Could not load theme.json. Theme and color scheme steps will be skipped." -ForegroundColor Yellow } + try { $terminalConfig = Read-JsonFile $Bundle.Terminal } + catch { Write-PspHost "Could not load terminal-config.json. Terminal behavior settings (font, scrollbar, keybindings) will not be applied." -ForegroundColor Yellow } + foreach ($pair in @(@($Bundle.Theme, 'theme.json', $profileConfig), @($Bundle.Terminal, 'terminal-config.json', $terminalConfig))) { + $cached = Join-Path $configCachePath $pair[1] + if ($null -ne $pair[2] -and $PSCmdlet.ShouldProcess($cached, "Cache $($pair[1])")) { Copy-Item -LiteralPath $pair[0] -Destination $cached -Force } + } -# Function to download Oh My Posh theme locally (skips download if file exists and is valid JSON) -function Install-OhMyPoshTheme { - param ( - [Parameter(Mandatory)] - [string]$ThemeName, - [Parameter(Mandatory)] - [string]$ThemeUrl - ) - $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 { - $null = Get-Content $themeFilePath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json - $alreadyValid = $true - } catch { $null = $_ } - } - if (-not $alreadyValid) { - Invoke-DownloadWithRetry -Uri $ThemeUrl -OutFile $themeFilePath - $null = Get-Content $themeFilePath -Raw -Encoding UTF8 | ConvertFrom-Json - Write-Host " Theme '$ThemeName' downloaded." -ForegroundColor Green + # Apply user-settings.json overrides (never downloaded, never overwritten). + $overrides = [PSCustomObject]@{ Managed = $true } + $userSettingsUnreadable = $false + if (Test-Path -LiteralPath $userSettingsPath) { + try { + $overrides = Merge-PspUserSettings -UserSettings (Read-JsonFile $userSettingsPath) -ThemeConfig $profileConfig -TerminalConfig $terminalConfig + Write-PspHost "User overrides applied from user-settings.json" -ForegroundColor DarkGray } - else { - Write-Host " Theme '$ThemeName' already present." -ForegroundColor Green + catch { + # Without the file it is unknown whether another tool owns Windows Terminal's settings.json, so it is left alone. + $overrides = [PSCustomObject]@{ Managed = $false } + $userSettingsUnreadable = $true + Write-PspHost "Failed to parse user-settings.json (left unchanged; Windows Terminal settings are skipped until it parses): $_" -ForegroundColor Yellow } - return $true } - catch { - Write-Host " Failed to download/validate theme: $_" -ForegroundColor Red - Remove-Item $themeFilePath -Force -ErrorAction SilentlyContinue - return $false + + if (-not $script:IsCiHost -and -not (Get-Command winget -CommandType Application -ErrorAction Ignore)) { + Write-PspHost "winget (App Installer) not found - skipping optional tool installs (eza, bat, fzf, zoxide, ripgrep, Oh My Posh)." -ForegroundColor Yellow + Write-PspHost " Install it later from the Microsoft Store or https://aka.ms/getwinget to add those tools." -ForegroundColor DarkYellow } -} -# Install or verify a winget package (deduplicates the 3x install pattern) -function Install-WingetPackage { - param ( - [Parameter(Mandatory)] - [string]$Name, - [Parameter(Mandatory)] - [string]$Id + Write-PspHost "" + Write-PspHost "PowerShell Profile Setup" -ForegroundColor Cyan + Write-PspHost "========================" -ForegroundColor Cyan + Write-PspHost "" + + # Profile creation or update (install for both PS5 and PS7) + Write-PspHost "[1/10] Profile" -ForegroundColor Cyan + # Derive Documents root from $PROFILE. + $docsRoot = Split-Path (Split-Path $PROFILE) + $profileDirs = @( + Join-Path $docsRoot "PowerShell" # PS7 (Core) + Join-Path $docsRoot "WindowsPowerShell" # PS5 (Desktop) ) - if (-not (Get-Command winget -ErrorAction Ignore)) { - Write-Host " winget not found. Skipping $Name." -ForegroundColor Yellow - return $false + $profileInstalled = Install-ProfileFiles -Source $Bundle.Profile -ProfileDirs $profileDirs + + # Create starter user-settings.json template if it doesn't exist (never overwrite) + if (-not (Test-Path -LiteralPath $userSettingsPath)) { + if ($PSCmdlet.ShouldProcess($userSettingsPath, 'Create the user-settings.json template')) { + Write-Utf8FileAtomic -Path $userSettingsPath -Content (Get-PspUserSettingsTemplate) + Write-PspHost " User settings template created at [$userSettingsPath]" -ForegroundColor Green + } } - $null = winget install -e --id $Id --accept-source-agreements --accept-package-agreements 2>&1 | Out-String - if ($LASTEXITCODE -eq 0) { - Write-Host " $Name installed." -ForegroundColor Green - return $true + else { + Write-PspHost " User settings file already exists at [$userSettingsPath] (preserved)" -ForegroundColor DarkGray } - elseif ($LASTEXITCODE -eq -1978335185 -or $LASTEXITCODE -eq -1978335189) { - # -1978335185 = already installed (winget install) - # -1978335189 = no applicable update (winget upgrade) - Write-Host " $Name already installed." -ForegroundColor Green - return $true + + # Use the wizard's editor choice or collect one interactively. + Write-PspHost "[2/10] Editor preference" -ForegroundColor Cyan + if ($script:WizardEditor) { + Write-PspHost " Using wizard choice: $($script:WizardEditor)" -ForegroundColor DarkGray + $chosenEditor = Resolve-ConfiguredEditor -RequestedEditor ([string]$script:WizardEditor) + Write-PspHost " Editor set to: $chosenEditor" -ForegroundColor Green + Set-PreferredEditorInProfiles -EditorName $chosenEditor -CommentLabel 'set by setup.ps1 wizard' -ProfileDirs $profileDirs + } + elseif ($script:CanPrompt -and -not $WhatIfPreference) { + $chosenEditor = Resolve-ConfiguredEditor -RequestedEditor (Select-PreferredEditor) + Write-PspHost " Editor set to: $chosenEditor" -ForegroundColor Green + Set-PreferredEditorInProfiles -EditorName $chosenEditor -CommentLabel 'set by setup.ps1' -ProfileDirs $profileDirs } else { - Write-Host " $Name install may have failed (exit code: $LASTEXITCODE)" -ForegroundColor Red - return $false + Write-PspHost " Skipped (non-interactive). Default: code, notepad" -ForegroundColor Yellow } -} -# OMP Install -Write-Host "[3/10] Oh My Posh" -ForegroundColor Cyan -$ompPath = Get-OhMyPoshExecutablePath -if ($ompPath -and $ompPath -notlike ((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps') + '*')) { - 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" -} -# 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 { - $reason = if (-not $profileConfig) { "theme.json missing" } - elseif (-not $profileConfig.theme -or -not $profileConfig.theme.name) { "theme name missing" } - else { "theme URL missing" } - Write-Host " Skipped theme download ($reason)." -ForegroundColor Yellow - $themeInstalled = $false -} -# Invalidate generated init caches. -foreach ($cacheFile in @('zoxide-init.ps1', 'omp-init.ps1')) { - Remove-Item (Join-Path $configCachePath $cacheFile) -Force -ErrorAction SilentlyContinue -} - -# Font Install -Write-Host "[4/10] Nerd Fonts" -ForegroundColor Cyan -$fontName = "CascadiaCode" -$fontDisplayName = "CaskaydiaCove NF" -$fontVersion = "3.2.1" -if ($terminalConfig -and $terminalConfig.fontInstall) { - if ($terminalConfig.fontInstall.name) { $fontName = $terminalConfig.fontInstall.name } - if ($terminalConfig.fontInstall.displayName) { $fontDisplayName = $terminalConfig.fontInstall.displayName } - if ($terminalConfig.fontInstall.version) { $fontVersion = $terminalConfig.fontInstall.version } -} -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 = $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 = $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 = $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 + # OMP Install + Write-PspHost "[3/10] Oh My Posh" -ForegroundColor Cyan + $ompPath = Get-OhMyPoshExecutablePath + if ($ompPath -and $ompPath -notlike ((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps') + '*')) { + Write-PspHost " Oh My Posh already present at $ompPath (preserved)." -ForegroundColor Green + $ompInstalled = $true } - catch { - Write-Host " Failed to install PSFzf module: $_" -ForegroundColor Red - $fzfInstalled = $false + elseif ($script:IsCiHost) { + Write-PspHost " CI mode: skipping Oh My Posh install." -ForegroundColor DarkGray + $ompInstalled = $true + } + else { + $ompInstalled = Install-WingetPackage -Name "Oh My Posh" -Id "JanDeDobbeleer.OhMyPosh" + } + # Wizard OMP-theme override wins if present; otherwise use theme.json (with user-settings overrides). + $configTheme = Get-PspProp $profileConfig 'theme' + if ($script:WizardOmpTheme) { + $themeInstalled = Install-OhMyPoshTheme -ThemeName $script:WizardOmpTheme.Name -ThemeUrl $script:WizardOmpTheme.Url -Sha256 ([string](Get-PspProp $script:WizardOmpTheme 'Sha256')) + } + elseif ((Get-PspProp $configTheme 'name') -and (Get-PspProp $configTheme 'url')) { + $themeInstalled = Install-OhMyPoshTheme -ThemeName ([string]$configTheme.name) -ThemeUrl ([string]$configTheme.url) -Sha256 ([string](Get-PspProp $configTheme 'sha256')) + } + else { + $reason = if (-not $profileConfig) { "theme.json missing" } + elseif (-not (Get-PspProp $configTheme 'name')) { "theme name missing" } + else { "theme URL missing" } + Write-PspHost " Skipped theme download ($reason)." -ForegroundColor Yellow + $themeInstalled = $false + } + # Invalidate generated init caches. + foreach ($cacheFile in @('zoxide-init.ps1', 'omp-init.ps1')) { + $cachePath = Join-Path $configCachePath $cacheFile + if ((Test-Path -LiteralPath $cachePath) -and $PSCmdlet.ShouldProcess($cachePath, 'Remove stale init cache')) { + Remove-Item -LiteralPath $cachePath -Force -ErrorAction SilentlyContinue + } + } + + # Font Install + Write-PspHost "[4/10] Nerd Fonts" -ForegroundColor Cyan + $fontInstall = Get-PspProp $terminalConfig 'fontInstall' + $fontName = [string](Get-PspProp $fontInstall 'name') + if (-not $fontName) { $fontName = 'CascadiaCode' } + $fontDisplayName = [string](Get-PspProp $fontInstall 'displayName') + if (-not $fontDisplayName) { $fontDisplayName = 'CaskaydiaCove NF' } + $fontVersion = [string](Get-PspProp $fontInstall 'version') + if (-not $fontVersion) { $fontVersion = $script:NerdFontVersion } + $fontSha256 = [string](Get-PspProp $fontInstall 'sha256') + if (-not $fontSha256 -and $fontVersion -eq $script:NerdFontVersion) { + $curated = $script:CuratedFonts | Where-Object { $_.Asset -eq $fontName } | Select-Object -First 1 + if ($curated) { $fontSha256 = $curated.Sha256 } + } + if ($script:WizardFontInstalled) { + Write-PspHost " Font already installed by wizard; skipping default." -ForegroundColor DarkGray + $fontInstalled = $true + } + elseif ($script:IsCiHost) { + Write-PspHost " CI mode: skipping Nerd Font install." -ForegroundColor DarkGray + $fontInstalled = $true + } + else { + $fontInstalled = Install-NerdFonts -FontName $fontName -FontDisplayName $fontDisplayName -Version $fontVersion -Sha256 $fontSha256 } -} -else { - Write-Host " PSFzf module already installed." -ForegroundColor Green -} - -# bat Install (syntax-highlighted cat replacement) -Write-Host "[8/10] bat" -ForegroundColor Cyan -$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 = $true -if ($isCiHost) { - Write-Host " CI mode: skipping ripgrep install." -ForegroundColor DarkGray -} -else { - $rgInstalled = Install-WingetPackage -Name "ripgrep" -Id "BurntSushi.ripgrep.MSVC" -} -# Merge font, theme, and appearance into every Windows Terminal variant. -Write-Host "[10/10] Windows Terminal" -ForegroundColor Cyan -$wtSettingsPaths = Get-WindowsTerminalSettingsPaths -if (-not $script:WtManaged) { - Write-Host " Skipped: user-settings.json sets windowsTerminal.manage = false, so settings.json is left to the tool that owns it." -ForegroundColor DarkGray - $wtSettingsPaths = @() -} -elseif (-not $wtSettingsPaths -or $wtSettingsPaths.Count -eq 0) { - Write-Host " Windows Terminal settings not found (skipped)." -ForegroundColor Yellow -} -foreach ($wtSettingsPath in $wtSettingsPaths) { - try { - # Backup settings.json before rewriting. - $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" - $backupPath = "$wtSettingsPath.$timestamp.bak" - Copy-Item $wtSettingsPath $backupPath -Force - Write-Host " Backup saved to $backupPath" -ForegroundColor DarkGray - - # Cleanup old WT backups (keep last 5) - $wtLocalState = Split-Path $wtSettingsPath - $oldBackups = Get-ChildItem -Path $wtLocalState -Filter "settings.json.*.bak" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 - foreach ($old in $oldBackups) { - Remove-Item $old.FullName -Force -ErrorAction SilentlyContinue - } - - # Read WT settings with retry. - $wt = $null - for ($wtAttempt = 1; $wtAttempt -le 2; $wtAttempt++) { + # eza Install (modern ls replacement with icons and git status) + Write-PspHost "[5/10] eza" -ForegroundColor Cyan + $ezaInstalled = $true + if ($script:IsCiHost) { + Write-PspHost " CI mode: skipping eza install." -ForegroundColor DarkGray + } + else { + $ezaInstalled = Install-WingetPackage -Name "eza" -Id "eza-community.eza" + } + # Terminal-Icons is not used by this profile (eza shows icons); remove it only when the user says so. + if ($script:CanPrompt -and -not $WhatIfPreference -and (Get-Module -ListAvailable -Name Terminal-Icons)) { + Write-PspHost ' The Terminal-Icons module is installed. This profile does not load it (eza provides icons).' -ForegroundColor DarkGray + if ((Read-WizardYesNo -Prompt ' Uninstall Terminal-Icons (all versions)? Other profiles that import it will lose it' -Default $false) -and + $PSCmdlet.ShouldProcess('Terminal-Icons (all versions)', 'Uninstall module')) { try { - $wtRaw = (Get-Content $wtSettingsPath -Raw -Encoding UTF8) -replace $jsoncCommentPattern, '' - $wt = $wtRaw | ConvertFrom-Json - break + Remove-Module Terminal-Icons -Force -ErrorAction SilentlyContinue + Uninstall-Module Terminal-Icons -AllVersions -Force -ErrorAction Stop + Write-PspHost ' Terminal-Icons uninstalled.' -ForegroundColor Green } - catch { - if ($wtAttempt -lt 2) { - Write-Host " WT settings parse failed, retrying in 1s..." -ForegroundColor Yellow - Start-Sleep -Seconds 1 - } - else { throw } - } - } - if (-not $wt) { $wt = [PSCustomObject]@{} } - - 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 + catch { Write-PspHost " Could not uninstall Terminal-Icons: $_" -ForegroundColor Yellow } } - $defaults = $wt.profiles.defaults + } - # Apply terminal-config.json defaults (font, opacity, scrollbar, etc.) - if ($terminalConfig -and $terminalConfig.defaults) { - $terminalConfig.defaults.PSObject.Properties | ForEach-Object { - $defaults | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value -Force - } - } + # zoxide Install + Write-PspHost "[6/10] zoxide" -ForegroundColor Cyan + $zoxideInstalled = $true + if ($script:IsCiHost) { + Write-PspHost " CI mode: skipping zoxide install." -ForegroundColor DarkGray + } + else { + $zoxideInstalled = Install-WingetPackage -Name "zoxide" -Id "ajeetdsouza.zoxide" + } - # Script params override JSON values when explicitly passed (work even without terminal-config.json) - if ($PSBoundParameters.ContainsKey('FontSize')) { - if (-not $defaults.font) { - $defaults | Add-Member -NotePropertyName "font" -NotePropertyValue ([PSCustomObject]@{}) -Force + # fzf + PSFzf Install (fuzzy finder for history and file search) + Write-PspHost "[7/10] fzf" -ForegroundColor Cyan + $fzfInstalled = $true + if ($script:IsCiHost) { + Write-PspHost " CI mode: skipping fzf and PSFzf install." -ForegroundColor DarkGray + } + else { + $fzfInstalled = Install-WingetPackage -Name "fzf" -Id "junegunn.fzf" + if (-not (Get-Module -ListAvailable -Name PSFzf)) { + $psfzfVersion = (Get-PspComponentPins).PSFzf + if ($PSCmdlet.ShouldProcess("PSFzf $psfzfVersion", 'Install PowerShell module from the PowerShell Gallery (CurrentUser)')) { + try { + Install-Module -Name PSFzf -RequiredVersion $psfzfVersion -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + Write-PspHost " PSFzf $psfzfVersion installed." -ForegroundColor Green + } + catch { + Write-PspHost " Failed to install PSFzf module: $_" -ForegroundColor Red + $fzfInstalled = $false + } } - $defaults.font | Add-Member -NotePropertyName "size" -NotePropertyValue $FontSize -Force } - if ($PSBoundParameters.ContainsKey('Opacity')) { - $defaults | Add-Member -NotePropertyName "opacity" -NotePropertyValue $Opacity -Force - } - - # Explicit -ColorScheme param wins over config - $cfgColorScheme = if ($PSBoundParameters.ContainsKey('ColorScheme')) { $ColorScheme } - elseif ($profileConfig -and $profileConfig.windowsTerminal -and $profileConfig.windowsTerminal.colorScheme) { $profileConfig.windowsTerminal.colorScheme } - else { $null } - $cfgCursorColor = if ($profileConfig -and $profileConfig.windowsTerminal -and $profileConfig.windowsTerminal.cursorColor) { $profileConfig.windowsTerminal.cursorColor } else { $null } - if ($cfgColorScheme) { - $defaults | Add-Member -NotePropertyName "colorScheme" -NotePropertyValue $cfgColorScheme -Force - } - if ($cfgCursorColor) { - $defaults | Add-Member -NotePropertyName "cursorColor" -NotePropertyValue $cfgCursorColor -Force + else { + Write-PspHost " PSFzf module already installed." -ForegroundColor Green } + } - # Upsert color scheme from config - if ($profileConfig -and $profileConfig.windowsTerminal -and $profileConfig.windowsTerminal.scheme) { - $schemeDef = [PSCustomObject]$profileConfig.windowsTerminal.scheme - if (-not $wt.schemes) { - $wt | Add-Member -NotePropertyName "schemes" -NotePropertyValue @() -Force - } - $wt.schemes = @(@($wt.schemes | Where-Object { $_ -and $_.name -ne $schemeDef.name }) + $schemeDef) - } + # bat Install (syntax-highlighted cat replacement) + Write-PspHost "[8/10] bat" -ForegroundColor Cyan + $batInstalled = $true + if ($script:IsCiHost) { + Write-PspHost " CI mode: skipping bat install." -ForegroundColor DarkGray + } + else { + $batInstalled = Install-WingetPackage -Name "bat" -Id "sharkdp.bat" + } - # 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 + # ripgrep Install (fast recursive grep, used by the grep function) + Write-PspHost "[9/10] ripgrep" -ForegroundColor Cyan + $rgInstalled = $true + if ($script:IsCiHost) { + Write-PspHost " CI mode: skipping ripgrep install." -ForegroundColor DarkGray + } + else { + $rgInstalled = Install-WingetPackage -Name "ripgrep" -Id "BurntSushi.ripgrep.MSVC" + } + + # Apply font, theme and appearance to every Windows Terminal variant through the change-recording writer used by Update-Profile. + Write-PspHost "[10/10] Windows Terminal" -ForegroundColor Cyan + $wtSettingsPaths = @(Get-WindowsTerminalSettingsPaths) + if (-not $overrides.Managed) { + $skipReason = if ($userSettingsUnreadable) { 'user-settings.json could not be parsed, so settings.json is left alone' } else { 'user-settings.json sets windowsTerminal.manage = false, so settings.json is left to the tool that owns it' } + Write-PspHost " Skipped: $skipReason." -ForegroundColor DarkGray + $wtSettingsPaths = @() + } + elseif ($wtSettingsPaths.Count -eq 0) { + Write-PspHost " Windows Terminal settings not found (skipped)." -ForegroundColor Yellow + } + $wtDefaults = Copy-PspObject (Get-PspProp $terminalConfig 'defaults') + if ($null -eq $wtDefaults) { $wtDefaults = [PSCustomObject]@{} } + # Script parameters override JSON values only when they are passed explicitly. + if ($script:BoundParameterNames -contains 'FontSize') { + $font = Get-PspProp $wtDefaults 'font' + if ($font -isnot [System.Management.Automation.PSCustomObject]) { $font = [PSCustomObject]@{} } + Set-PspProp $font 'size' $FontSize + Set-PspProp $wtDefaults 'font' $font + } + if ($script:BoundParameterNames -contains 'Opacity') { Set-PspProp $wtDefaults 'opacity' $Opacity } + $wtTheme = Copy-PspObject (Get-PspProp $profileConfig 'windowsTerminal') + if ($script:BoundParameterNames -contains 'ColorScheme') { + if ($null -eq $wtTheme) { $wtTheme = [PSCustomObject]@{} } + Set-PspProp $wtTheme 'colorScheme' $ColorScheme + } + foreach ($wtSettingsPath in $wtSettingsPaths) { + try { + $wtArgs = @{ + Path = $wtSettingsPath + ManifestPath = (Join-Path $configCachePath 'wt-changes.json') + Defaults = $wtDefaults + WindowsTerminal = $wtTheme + Keybindings = Get-PspArray $terminalConfig 'keybindings' + NoLogo = $true } - $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 } - } - - # Launch PowerShell profiles with -NoLogo. - if ($wt.profiles.list) { - foreach ($prof in @($wt.profiles.list)) { - if (-not $prof) { continue } - $cmd = if ($prof.commandline) { $prof.commandline } else { '' } - $src = if ($prof.source) { $prof.source } else { '' } - $isPwsh = $cmd -match 'pwsh' -or $src -match 'Windows\.Terminal\.PowerShellCore' - $isPS5 = $cmd -match 'powershell\.exe' -or $prof.name -match 'Windows PowerShell' - if ($isPwsh -or $isPS5) { - if ($cmd -and $cmd -notmatch '-NoLogo' -and $cmd -notmatch '(?i)-(Command|File|EncodedCommand)') { - $prof | Add-Member -NotePropertyName "commandline" -NotePropertyValue "$cmd -NoLogo" -Force - } - elseif (-not $cmd -and $src) { - $exe = if ($isPwsh) { 'pwsh.exe' } else { 'powershell.exe' } - $prof | Add-Member -NotePropertyName "commandline" -NotePropertyValue "$exe -NoLogo" -Force - } - } + if (Update-WindowsTerminalSettingsFile @wtArgs) { + $schemeLabel = if (Get-PspProp $wtTheme 'colorScheme') { $wtTheme.colorScheme } else { '(unchanged)' } + Write-PspHost " Windows Terminal configured: $wtSettingsPath (scheme: $schemeLabel)." -ForegroundColor Green } } - - # Apply keybindings from terminal-config.json - if ($terminalConfig -and $terminalConfig.keybindings) { - if (-not $wt.actions) { - $wt | Add-Member -NotePropertyName "actions" -NotePropertyValue @() -Force - } - foreach ($kb in $terminalConfig.keybindings) { - if (-not $kb -or [string]::IsNullOrWhiteSpace($kb.keys)) { continue } - $bindingId = "User.profile.$($kb.keys -replace '[^a-zA-Z0-9]', '')" - if ($wt.PSObject.Properties['keybindings']) { - # New WT format: separate keybindings array references actions by id - $existingIds = @($wt.keybindings | Where-Object { $_.keys -eq $kb.keys } | ForEach-Object { $_.id }) - if ($existingIds.Count -gt 0) { - $wt.actions = @($wt.actions | Where-Object { $_ -and ($existingIds -notcontains $_.id) }) - $wt.keybindings = @($wt.keybindings | Where-Object { $_ -and $_.keys -ne $kb.keys }) - } - $wt.actions = @($wt.actions) + ([PSCustomObject]@{ command = $kb.command; id = $bindingId }) - $wt.keybindings = @($wt.keybindings) + ([PSCustomObject]@{ id = $bindingId; keys = $kb.keys }) - } - else { - # Old WT format: keys directly in actions - $wt.actions = @($wt.actions | Where-Object { $_ -and $_.keys -ne $kb.keys }) - $wt.actions = @($wt.actions) + ([PSCustomObject]@{ keys = $kb.keys; command = $kb.command }) - } - } + catch { + Write-PspHost " Failed to configure Windows Terminal ($wtSettingsPath): $_" -ForegroundColor Red } - - # Preserve deeply nested Windows Terminal action objects during serialization. - $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 ($wtSettingsPath): $_" -ForegroundColor Red + + # Optional PowerShell telemetry opt-out (explicit consent). + if (-not $script:WizardTelemetryHandled -and $script:CanPrompt -and -not $WhatIfPreference -and -not (Test-TelemetryOptOutSet)) { + Write-PspHost "" + $scopeLabel = if ($script:IsElevated) { 'machine-wide' } else { 'for your user account' } + Write-PspHost "Opt out of PowerShell telemetry? This sets POWERSHELL_TELEMETRY_OPTOUT=true $scopeLabel." -ForegroundColor Cyan + $answer = Read-Host " [y/N]" + if ($answer -match '^(?i:y|yes)$') { Set-TelemetryOptOut -CacheDir $configCachePath } } -} -# Optional PowerShell telemetry opt-out (explicit consent, machine-wide env var, requires admin). -$canPromptTelemetry = [Environment]::UserInteractive -and -not $isCiHost -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)$') { + # Record what was installed so psp-doctor, the update check and Uninstall-Profile know the source. + if ($profileInstalled -and $PSCmdlet.ShouldProcess((Join-Path $configCachePath 'installed.json'), 'Record the installed version and source')) { try { - [System.Environment]::SetEnvironmentVariable('POWERSHELL_TELEMETRY_OPTOUT', 'true', [System.EnvironmentVariableTarget]::Machine) - # Record ownership so uninstall removes only the telemetry setting created here. - 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 + $installedVersion = [regex]::Match((Get-Utf8FileText $Bundle.Profile), "(?m)^\s*Version\s*=\s*'([^']+)'").Groups[1].Value + $sourceKind = if ($LocalRepo) { 'local' } else { 'remote' } + $refLabel = if ($LocalRepo) { $LocalRepo } else { $Ref } + Set-PspInstallState -CacheDir $configCachePath -Version $installedVersion -Ref $refLabel -Commit $script:SourceCommit -BundleSha256 $script:BundleSha256 -Source $sourceKind + if ($script:SourceCommit) { Write-Utf8FileAtomic -Path (Join-Path $configCachePath 'applied-commit.sha') -Content $script:SourceCommit } } + catch { Write-PspHost " Could not record the install state: $_" -ForegroundColor Yellow } } -} -# Base the final exit code on profile installation while reporting optional tool failures as warnings. -Write-Host "" -$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 complete!" -ForegroundColor Green + # Base the final result on profile installation while reporting optional tool failures as warnings. + Write-PspHost "" + $optionalOk = $themeInstalled -and $fontInstalled -and $ompInstalled -and $ezaInstalled -and $zoxideInstalled -and $fzfInstalled -and $batInstalled -and $rgInstalled + if ($WhatIfPreference) { + Write-PspHost "WhatIf: nothing was changed." -ForegroundColor Yellow + } + elseif (-not $profileInstalled) { + Write-PspHost "Setup FAILED: the profile could not be installed. Check the messages above." -ForegroundColor Red + } + elseif (-not $optionalOk) { + Write-PspHost "Setup complete - profile installed. Some optional tools were skipped or failed (see above); the profile works without them." -ForegroundColor Yellow + } + else { + Write-PspHost "Setup complete!" -ForegroundColor Green + } + Write-PspHost "" + return [bool]$profileInstalled } -Write-Host "" -# Skip the restart prompt in automated environments, including -CiMode runs driven by the test suite. -$canPromptExit = [Environment]::UserInteractive -and -not $isCiHost -and -not [bool]$env:AI_AGENT -if ($canPromptExit) { try { $null = [Console]::KeyAvailable } catch { $canPromptExit = $false } } -# Restart local and piped installs in Windows Terminal, pwsh, or powershell. -if ($canPromptExit) { - Write-Host "Setup applied. Restarting terminal..." -ForegroundColor Green - Start-Sleep -Seconds 2 - Write-Host "Press Enter to restart (or close this window to cancel)..." -ForegroundColor Yellow + +# Restart into a fresh terminal so the profile loads; an elevated setup only says so, to avoid handing the user an elevated shell. +function Restart-SetupTerminal { + param([int]$ExitCode) + if (-not $script:CanPrompt -or $WhatIfPreference) { return } + if ($script:IsElevated) { + Write-PspHost 'Open a new, non-elevated terminal to start using the profile.' -ForegroundColor Yellow + return + } + Write-PspHost "Setup applied. Press Enter to restart the terminal (or close this window to cancel)..." -ForegroundColor Yellow try { $null = Read-Host } catch { $null = $_ } $dir = (Get-Location).Path if (-not $dir -or -not (Test-Path -LiteralPath $dir -PathType Container -ErrorAction SilentlyContinue)) { $dir = [Environment]::GetFolderPath('UserProfile') } - $shellName = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh" } else { "powershell" } - if (Get-Command wt.exe -ErrorAction Ignore) { - Start-Process -FilePath "wt.exe" -ArgumentList "-w", "0", "-d", $dir, $shellName, "-NoExit" + $shellExe = Get-PspShellPath + $wt = Get-Command wt.exe -CommandType Application -ErrorAction Ignore | Select-Object -First 1 + if ($wt) { + Start-Process -FilePath $wt.Source -ArgumentList (ConvertTo-NativeArgumentLine @('-w', '0', '-d', $dir, $shellExe, '-NoExit')) } else { - $shellExe = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh.exe" } else { "powershell.exe" } - Start-Process -FilePath $shellExe -ArgumentList "-NoExit" -WorkingDirectory $dir + Start-Process -FilePath $shellExe -ArgumentList '-NoExit' -WorkingDirectory $dir + } + if ($script:TranscriptStarted) { try { Stop-Transcript | Out-Null } catch { $null = $_ }; $script:TranscriptStarted = $false } + if ($script:WorkDir) { Remove-SafeTempDirectory -Path $script:WorkDir -NamePrefix 'psp-setup-' } + exit $ExitCode +} + +# --------------------------------------------------------------------------------------------- +# Main flow +# --------------------------------------------------------------------------------------------- +$exitCode = 1 +try { + $onWindows = (-not (Test-Path Variable:\IsWindows)) -or $IsWindows + if (-not $onWindows) { throw 'setup.ps1 supports Windows only.' } + $build = [Environment]::OSVersion.Version.Build + if ($build -lt 17763) { + Write-PspHost "Windows build $build is older than Windows 10 1809: fonts can only be installed from an elevated shell." -ForegroundColor Yellow + } + if ($SkipHashCheck -and $ExpectedSha256) { throw 'Use either -ExpectedSha256 or -SkipHashCheck, not both.' } + if ($LocalRepo -and ($script:BoundParameterNames -contains 'Ref')) { throw 'Use either -LocalRepo or -Ref, not both.' } + + # A folder holding setup.ps1 next to the profile files is a local checkout, unless the caller pinned a ref or hash. + if ([string]::IsNullOrWhiteSpace($LocalRepo) -and $PSScriptRoot -and -not ($script:BoundParameterNames -contains 'Ref') -and -not $ExpectedSha256) { + $here = [System.IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + $repoFiles = @('Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json', 'setprofile.ps1') + $missing = @($repoFiles | Where-Object { -not (Test-Path -LiteralPath (Join-Path $here $_) -PathType Leaf) }) + if ($here -ne $tempRoot -and $missing.Count -eq 0) { + $LocalRepo = $here + Write-PspHost "Using local repo checkout: $LocalRepo" -ForegroundColor DarkGray + } + } + + # Verify (or locate) the bundle before any local change. + try { $script:Bundle = Initialize-InstallBundle } + catch { + Write-PspHost "Install bundle was not applied: $($_.Exception.Message)" -ForegroundColor Red + Write-PspHost "No local setup changes were made." -ForegroundColor Yellow + $script:ErrorReported = $true + throw + } + + # Load the shared helpers from the verified profile into this script's scope. + foreach ($definition in (Get-SharedFunctionDefinitions -ProfilePath $script:Bundle.Profile -Names $script:SharedFunctionNames)) { + . ([scriptblock]::Create($definition)) + } + + # Log the run (Write-Host output included) to the cache folder; dry runs are not logged. + if (-not $WhatIfPreference) { + try { + $logDir = Join-Path (Get-PspCacheDir) 'logs' + if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + Get-ChildItem -LiteralPath $logDir -Filter 'setup-*.log' -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -Skip 9 | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } + $logPath = Join-Path $logDir ('setup-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + Start-Transcript -LiteralPath $logPath -ErrorAction Stop | Out-Null + $script:TranscriptStarted = $true + Write-PspHost "Logging to $logPath" -ForegroundColor DarkGray + } + catch { Write-PspHost " Could not start the setup log: $($_.Exception.Message)" -ForegroundColor Yellow } } - exit ([int](-not $profileInstalled)) + + $installed = Invoke-SetupInstall -Bundle $script:Bundle + $exitCode = if ($installed -or $WhatIfPreference) { 0 } else { 1 } + # A failed install keeps this window open with its messages. + if ($installed) { Restart-SetupTerminal -ExitCode $exitCode } +} +catch { + if (-not $script:ErrorReported) { Write-PspHost "Setup stopped: $($_.Exception.Message)" -ForegroundColor Red } + $exitCode = 1 } -if ($MyInvocation.PSCommandPath) { - exit ([int](-not $profileInstalled)) +finally { + if ($script:TranscriptStarted) { try { Stop-Transcript | Out-Null } catch { $null = $_ } } + if ($script:WorkDir) { Remove-SafeTempDirectory -Path $script:WorkDir -NamePrefix 'psp-setup-' } +} + +# Exit with the code when run as a file; a script block must not close the caller's shell or leave setup's variables in it. +if ($PSCommandPath) { exit $exitCode } +foreach ($name in @(Get-Variable -Scope Script -ErrorAction Ignore | ForEach-Object { $_.Name })) { + if ($callerScriptVariables -notcontains $name) { Remove-Variable -Name $name -Scope Script -Force -ErrorAction Ignore -WhatIf:$false -Confirm:$false } } +$global:LASTEXITCODE = $exitCode diff --git a/terminal-config.json b/terminal-config.json index dc4c521..a4557ef 100644 --- a/terminal-config.json +++ b/terminal-config.json @@ -15,7 +15,8 @@ "fontInstall": { "name": "CascadiaCode", "displayName": "CaskaydiaCove NF", - "version": "3.2.1" + "version": "3.5.1", + "sha256": "1298BF92698AFA06185CF1D05E6AE05F2D8A1E8C3CB45DDF4C3035168AB342A1" }, "keybindings": [ { diff --git a/tests/ci-functional.ps1 b/tests/ci-functional.ps1 index d173534..e856f40 100644 --- a/tests/ci-functional.ps1 +++ b/tests/ci-functional.ps1 @@ -1,8 +1,20 @@ +# Functional suite: installs into a sandbox, runs every profile command it safely can, audits coverage, then uninstalls. +# Third-party web endpoints are answered by local mocks unless -LiveNetwork is passed (the scheduled workflow does that). +# pwsh -NoProfile -File tests/ci-functional.ps1 # hermetic, as in pull-request CI +# pwsh -NoProfile -File tests/ci-functional.ps1 -LiveNetwork # also call the real endpoints (never uploads to the public paste) # Suppress alias warnings because the test invokes profile commands by their short names. [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '')] param( [switch]$VerboseOutput, - [switch]$StrictNetwork + [switch]$StrictNetwork, + # Call the real third-party endpoints instead of the local mocks. + [switch]$LiveNetwork, + # With -LiveNetwork, let hb really publish its probe file to the public paste service. + [switch]$AllowPublicUpload, + # CI runners are elevated disposable VMs; elsewhere run the suite from a normal shell. + [switch]$AllowElevated, + # Registration is not execution: fail when fewer exports than this actually run. + [ValidateRange(0, 100)][int]$MinimumExecutedPercent = 60 ) $ErrorActionPreference = 'Stop' @@ -10,6 +22,8 @@ $ErrorActionPreference = 'Stop' # Resolve repository assets relative to the parent of tests/. $repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) $profilePath = Join-Path $repoRoot 'Microsoft.PowerShell_profile.ps1' +. (Join-Path $PSScriptRoot 'lib\Sandbox.ps1') +Assert-PspTestSafety -AllowElevated:$AllowElevated -ScriptName 'tests/ci-functional.ps1' $passed = 0 $failed = 0 @@ -21,9 +35,9 @@ $script:commandFailures = @() $script:networkSoftFails = @() $script:installReady = $false -$script:origLocalAppData = $env:LOCALAPPDATA -$script:origProfileVar = $PROFILE -$script:origCiVar = $env:CI +# Everything below runs with LOCALAPPDATA, TEMP and $PROFILE inside this sandbox and machine-changing commands blocked. +$script:runSandbox = New-PspSandbox -Name 'functional' +Enter-PspSandbox -Sandbox $script:runSandbox $script:sandboxRoot = $null $script:sandboxPs7Dir = $null @@ -119,40 +133,69 @@ function Register-SkippedCommand { } } -# Detect if error message indicates a network/connectivity issue (for soft-fail in probes) -function Test-IsNetworkIssue { - param([string]$Message) - if ([string]::IsNullOrWhiteSpace($Message)) { return $false } - $patterns = @( - '(?i)timeout' - '(?i)timed out' - '(?i)unable to connect' - '(?i)could not connect' - '(?i)connection.*(closed|failed|forcibly|refused|reset)' - '(?i)remote name could not be resolved' - '(?i)no such host' - '(?i)name or service not known' - '(?i)dns' - '(?i)network' - '(?i)unreachable' - '(?i)TLS' - '(?i)SSL' - '(?i)HttpRequestException' - '(?i)temporar' - '(?i)service unavailable' - '(?i)\b(429|500|502|503|504)\b' - '(?i)lookup failed' - '(?i)could not fetch' - '(?i)failed to retrieve public ip' - '(?i)whois lookup failed' - '(?i)failed to upload the document' - ) - foreach ($pattern in $patterns) { - if ($Message -match $pattern) { return $true } +# Return $true when an exception chain contains a transport failure (DNS, TCP, TLS handshake, timeout), judged by type, not message text. +function Test-IsTransportFailure { + param([System.Exception]$Exception) + # Compared by name: System.Net.Http is not always loaded in Windows PowerShell 5.1. + $transportTypes = @('System.Net.Http.HttpRequestException', 'System.Net.WebException', 'System.Net.Sockets.SocketException', 'System.TimeoutException', 'System.Threading.Tasks.TaskCanceledException', 'System.Security.Authentication.AuthenticationException') + for ($e = $Exception; $e; $e = $e.InnerException) { + for ($t = $e.GetType(); $t; $t = $t.BaseType) { if ($transportTypes -contains $t.FullName) { return $true } } } return $false } +# Answer third-party web requests with canned responses so pull-request CI never calls or uploads to external services. +# Loopback requests (the local HTTP probe) and GitHub file downloads (pinned themes, whose hashes are then checked +# against the real files) go to the real cmdlets; any other unmocked URL fails the probe. +function Install-NetworkMocks { + param([switch]$UploadOnly) + $global:PspMockUploadOnly = [bool]$UploadOnly + $global:PspMockResponse = { + param([string]$Uri) + $u = [Uri]$Uri + $suffix = '1E4C9B93F3F0682250B6CF8331B7EE68FD8' + if ($u.Host -eq 'bin.christitus.com') { return [PSCustomObject]@{ key = 'mockkey' } } + if ($global:PspMockUploadOnly) { return $null } + switch -Regex ($u.Host) { + '^ifconfig\.me$' { return '203.0.113.7' } + '^ipwho\.is$' { return [PSCustomObject]@{ success = $true; ip = '8.8.8.8'; city = 'Mountain View'; region = 'California'; country = 'United States'; connection = [PSCustomObject]@{ isp = 'Mock ISP'; org = 'Mock Org'; asn = 64500 } } } + '^rdap\.org$' { return [PSCustomObject]@{ ldhName = 'EXAMPLE.COM'; status = @('active'); entities = @(); events = @([PSCustomObject]@{ eventAction = 'registration'; eventDate = '1995-08-14T04:00:00Z' }); nameservers = @([PSCustomObject]@{ ldhName = 'A.IANA-SERVERS.NET' }) } } + '^wttr\.in$' { return 'London: +12C' } + '^api\.pwnedpasswords\.com$' { return "${suffix}:42`r`n0018A45C4D1DEF81644B54AB7F969B88D65:1" } + '^www\.toptal\.com$' { return "# gitignore mock`n__pycache__/`n" } + '^raw\.githubusercontent\.com$' { if ($u.AbsolutePath -like '/tldr-pages/*') { return "# ls`n`n> List directory contents." } } + '^api\.github\.com$' { + if ($u.AbsolutePath -like '/repos/PowerShell/PowerShell/releases/latest') { return [PSCustomObject]@{ tag_name = "v$($PSVersionTable.PSVersion)" } } + if ($u.AbsolutePath -like '/repos/26zl/PowerShellPerfect/commits/*') { return ('a' * 40) } + } + } + throw "Unmocked network call in CI: $Uri (add a mock or run with -LiveNetwork)" + } + Set-Item -Path 'function:global:Invoke-RestMethod' -Value { + param([Parameter(Position = 0)][string]$Uri, [string]$Method, $Body, $Headers, [int]$TimeoutSec, [switch]$UseBasicParsing, [string]$OutFile, [string]$ContentType) + $target = [Uri]$Uri + $passThrough = $target.IsLoopback -or $target.Host -in @('github.com', 'objects.githubusercontent.com') -or + ($target.Host -eq 'raw.githubusercontent.com' -and $target.AbsolutePath -notlike '/tldr-pages/*') + if ($passThrough -or ($global:PspMockUploadOnly -and $target.Host -ne 'bin.christitus.com')) { + $null = $PSBoundParameters.Remove('ErrorAction') + return Microsoft.PowerShell.Utility\Invoke-RestMethod @PSBoundParameters -ErrorAction Stop + } + $result = & $global:PspMockResponse $Uri + if ($OutFile) { [System.IO.File]::WriteAllText($OutFile, [string]$result, [System.Text.UTF8Encoding]::new($false)); return } + $result + } + Set-Item -Path 'function:global:Invoke-WebRequest' -Value { + param([Parameter(Position = 0)][string]$Uri, [string]$Method, $Body, $Headers, [int]$TimeoutSec, [switch]$UseBasicParsing, [string]$OutFile, [string]$ContentType) + $target = [Uri]$Uri + if ($target.IsLoopback -or $global:PspMockUploadOnly) { + $null = $PSBoundParameters.Remove('ErrorAction') + return Microsoft.PowerShell.Utility\Invoke-WebRequest @PSBoundParameters -ErrorAction Stop + } + $result = & $global:PspMockResponse $Uri + [PSCustomObject]@{ StatusCode = 200; Content = [string]$result; Headers = @{ 'Content-Type' = 'text/plain' }; RawContentStream = [System.IO.MemoryStream]::new([System.Text.Encoding]::UTF8.GetBytes([string]$result)) } + } +} + # Run a single command probe and record result for coverage audit function Invoke-CommandProbe { param( @@ -173,23 +216,28 @@ function Invoke-CommandProbe { return } + $failure = $null try { $output = & { $ErrorActionPreference = 'Stop'; & $Code } 2>&1 $errorRecords = @($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) - if ($errorRecords.Count -gt 0) { throw $errorRecords[0].ToString() } + if ($errorRecords.Count -gt 0) { $failure = $errorRecords[0] } - if ($VerboseOutput -and $output) { + if (-not $failure -and $VerboseOutput -and $output) { foreach ($line in $output) { Write-Host " $line" -ForegroundColor DarkGray } } + } + catch { $failure = $_ } + if (-not $failure) { Register-ExecutedCommand -Name $Command Write-Host " OK $Command" -ForegroundColor Green } - catch { - $msg = $_.Exception.Message - if (Test-IsNetworkIssue -Message $msg) { + else { + $msg = $failure.Exception.Message + # Only a real transport failure during a live run is a soft failure; mocked runs never touch the network. + if ($LiveNetwork -and (Test-IsTransportFailure -Exception $failure.Exception)) { Register-ExecutedCommand -Name $Command $script:networkSoftFails += "${Command}: $msg" Write-Host " NET $Command ($msg)" -ForegroundColor Yellow @@ -202,26 +250,13 @@ function Invoke-CommandProbe { } } -# Restore environment and remove sandbox dir after test run +# Restore environment and remove sandbox dirs after test run function Restore-SandboxEnvironment { - if ($null -ne $script:origLocalAppData) { - $env:LOCALAPPDATA = $script:origLocalAppData - } - - if ($script:origProfileVar) { - Set-Variable -Name PROFILE -Scope Global -Value $script:origProfileVar - } - - if ([string]::IsNullOrWhiteSpace($script:origCiVar)) { - Remove-Item env:CI -ErrorAction SilentlyContinue - } - else { - $env:CI = $script:origCiVar - } - + foreach ($stub in 'Invoke-RestMethod', 'Invoke-WebRequest') { Remove-Item -Path "function:global:$stub" -ErrorAction SilentlyContinue } if ($script:sandboxRoot -and (Test-Path $script:sandboxRoot)) { Remove-Item -Path $script:sandboxRoot -Recurse -Force -ErrorAction SilentlyContinue } + Exit-PspSandbox -Sandbox $script:runSandbox } Write-Host '' @@ -232,6 +267,15 @@ if (-not (Test-Path $profilePath)) { throw "Profile not found: $profilePath" } +if ($LiveNetwork) { + Write-Host ' Live network mode: probes call the real endpoints.' -ForegroundColor Yellow + if (-not $AllowPublicUpload) { Install-NetworkMocks -UploadOnly } +} +else { + Write-Host ' Mocked network mode: third-party endpoints are answered locally.' -ForegroundColor DarkGray + Install-NetworkMocks +} + try { Invoke-TestCase -Name 'Full install flow in sandbox (setup.ps1)' -Code { # Validate setup.ps1 against a disposable sandbox. @@ -317,7 +361,8 @@ Invoke-TestCase -Name 'Install profile in sandbox' -Code { [System.IO.File]::WriteAllText((Join-Path $script:sandboxPs7Dir 'profile_user.ps1'), '# user override', $utf8NoBom) [System.IO.File]::WriteAllText((Join-Path $script:sandboxPs5Dir 'profile_user.ps1'), '# user override', $utf8NoBom) - $smokeOutput = pwsh -NoProfile -NonInteractive -Command ". '$($script:sandboxPs7Profile)'" 2>&1 + # The child computes its own $PROFILE; point it at the sandbox so no real profile_user.ps1 runs. + $smokeOutput = pwsh -NoProfile -NonInteractive -Command "`$global:PROFILE = '$($script:sandboxPs7Profile)'; . '$($script:sandboxPs7Profile)'" 2>&1 if ($LASTEXITCODE -ne 0) { $sample = ($smokeOutput | Select-Object -First 5) -join '; ' throw "Installed profile failed to load in sandbox smoke test: $sample" @@ -336,6 +381,7 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Set-Variable -Name PROFILE -Scope Global -Value $script:sandboxPs7Profile $env:CI = 'true' . $script:sandboxPs7Profile + . $PspPostLoadStubs $workspace = Join-Path $env:TEMP "psp-ci-func-$([System.IO.Path]::GetRandomFileName())" $utf8NoBom = [System.Text.UTF8Encoding]::new($false) @@ -409,11 +455,18 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $listener.Close() } } - Start-Sleep -Milliseconds 200 - - # Environment feature detection - $clipboardReady = [bool](Get-Command Set-Clipboard -ErrorAction SilentlyContinue) -and [bool](Get-Command Get-Clipboard -ErrorAction SilentlyContinue) - $clipboardSkipReason = $null + # Start-Job launches a new process; wait until the listener accepts connections instead of guessing a delay. + $httpReadyBy = (Get-Date).AddSeconds(20) + do { + $probeClient = [System.Net.Sockets.TcpClient]::new() + try { $probeClient.Connect([System.Net.IPAddress]::Loopback, $httpPort); $httpReady = $true } + catch { $httpReady = $false; Start-Sleep -Milliseconds 200 } + finally { $probeClient.Dispose() } + } until ($httpReady -or (Get-Date) -gt $httpReadyBy) + + # Environment feature detection; clipboard probes overwrite the clipboard, so they run on CI runners only. + $clipboardReady = [bool]$env:GITHUB_ACTIONS -and [bool](Get-Command Set-Clipboard -ErrorAction SilentlyContinue) -and [bool](Get-Command Get-Clipboard -ErrorAction SilentlyContinue) + $clipboardSkipReason = if (-not $env:GITHUB_ACTIONS) { 'Would overwrite your clipboard (runs on CI runners only)' } else { $null } if ($clipboardReady) { try { Set-Clipboard 'psp-ci-probe' @@ -425,9 +478,10 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $clipboardSkipReason = "Clipboard unavailable in this host: $($_.Exception.Message)" } } - else { + elseif (-not $clipboardSkipReason) { $clipboardSkipReason = 'Set-Clipboard/Get-Clipboard not available' } + $liveSkipReason = if ($LiveNetwork) { $null } else { 'Real TLS endpoint; covered by the scheduled -LiveNetwork run' } $hasDocker = [bool](Get-Command docker -ErrorAction SilentlyContinue) $dockerRunning = $false @@ -481,15 +535,13 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Update-PowerShell -WhatIf *> $null } Invoke-CommandProbe -Command 'Update-Tools' -Code { - # Exercise Update-Tools only when winget is absent. - 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' } + # winget is replaced by a recorder and one tool is reported as installed, so the upgrade path runs without changing anything. + $script:wingetCalls = @() + function winget { $script:wingetCalls += ($args -join ' '); $global:LASTEXITCODE = -1978335189 } + function Get-ProfileToolExecutablePath { param($Tool) if ($Tool.Name -eq 'bat') { 'C:\psp-test\bat.exe' } } + function Get-ProfileToolVersionText { param($Tool, $ExecutablePath) '1.0.0' } + Update-Tools -Confirm:$false *> $null + if (@($script:wingetCalls | Where-Object { $_ -match '^upgrade --id sharkdp\.bat\b' }).Count -ne 1) { throw "unexpected winget calls: $($script:wingetCalls -join ' | ')" } } Invoke-CommandProbe -Command 'Clear-ProfileCache' -Code { $origLocal = $env:LOCALAPPDATA @@ -630,7 +682,13 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $size = sizeof $textFile if (-not $size) { throw 'sizeof returned empty value' } } - Invoke-CommandProbe -Command 'trash' -SkipReason 'Shell.Application recycle-bin COM is unreliable in headless CI' + # -WhatIf: a real run would move the file into the Recycle Bin of the account running the tests. + Invoke-CommandProbe -Command 'trash' -Code { + $trashFile = Join-Path $workspace 'trash-probe.txt' + [System.IO.File]::WriteAllText($trashFile, 'x', $utf8NoBom) + trash $trashFile -WhatIf + if (-not (Test-Path -LiteralPath $trashFile)) { throw 'trash -WhatIf removed the file' } + } Invoke-CommandProbe -Command 'extract' -Code { New-Item -ItemType Directory -Path $extractDir -Force | Out-Null $before = Get-Location @@ -735,7 +793,11 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $r = pgrep 'pwsh' if (-not $r) { throw 'pgrep did not return any pwsh process' } } - Invoke-CommandProbe -Command 'pkill' -SkipReason 'Destructive process termination command' + Invoke-CommandProbe -Command 'pkill' -Code { + pkill 'psp-no-such-process' 3>$null + pkill '*' -ErrorAction SilentlyContinue -ErrorVariable pkillErr + if ((@($pkillErr) -join ' ') -notmatch 'refusing') { throw 'pkill accepted a match-everything pattern' } + } Invoke-CommandProbe -Command 'export' -Code { export 'PSP_TEST_VAR' 'ci' if ($env:PSP_TEST_VAR -ne 'ci') { throw 'export did not set env var' } @@ -752,7 +814,7 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Invoke-CommandProbe -Command 'ports' -Code { ports | Out-Null } Invoke-CommandProbe -Command 'checkport' -SkipReason 'Test-NetConnection can fail on hosted runners without stable adapter metadata' Invoke-CommandProbe -Command 'portscan' -Code { portscan '127.0.0.1' -Ports @($localPort) | Out-Null } - Invoke-CommandProbe -Command 'tlscert' -Code { tlscert 'example.com' | Out-Null } + Invoke-CommandProbe -Command 'tlscert' -Code { tlscert 'example.com' | Out-Null } -SkipReason $liveSkipReason Invoke-CommandProbe -Command 'ipinfo' -Code { ipinfo '8.8.8.8' | Out-Null } Invoke-CommandProbe -Command 'whois' -Code { whois 'example.com' | Out-Null } Invoke-CommandProbe -Command 'nslook' -Code { nslook 'localhost' | Out-Null } @@ -763,8 +825,38 @@ 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 'Remote-script wrapper is unit-tested separately; live run intentionally skipped' - Invoke-CommandProbe -Command 'harden' -SkipReason 'Launches external hardening tool / GUI on developer machines' + Invoke-CommandProbe -Command 'winutil' -Code { + # Serve a local script: winutil must not run it without opt-in or after a hash mismatch, and must run it after a match. + $marker = Join-Path $workspace 'winutil-ran.txt' + $body = "[System.IO.File]::WriteAllText('$($marker -replace "'", "''")', 'ran', [System.Text.UTF8Encoding]::new(`$false))" + $script:winutilBytes = [System.Text.Encoding]::UTF8.GetBytes($body) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { $good = [BitConverter]::ToString($sha.ComputeHash($script:winutilBytes)).Replace('-', '') } finally { $sha.Dispose() } + function Invoke-WebRequest { param([string]$Uri, [int]$TimeoutSec, [switch]$UseBasicParsing, $ErrorAction) [PSCustomObject]@{ RawContentStream = [System.IO.MemoryStream]::new($script:winutilBytes) } } + $savedAdmin = $script:PSP.IsAdmin + try { + winutil 6>$null + # Unelevated, WinUtil would relaunch itself from its latest release, so a pinned run needs an elevated shell. + $script:PSP.IsAdmin = $false + winutil -ExpectedSha256 $good -Confirm:$false -ErrorAction SilentlyContinue 6>$null + if (Test-Path -LiteralPath $marker) { throw 'winutil ran unelevated' } + $script:PSP.IsAdmin = $true + winutil -ExpectedSha256 ('0' * 64) -Confirm:$false -ErrorAction SilentlyContinue 6>$null + if (Test-Path -LiteralPath $marker) { throw 'winutil ran without opt-in or after a hash mismatch' } + winutil -ExpectedSha256 $good -Confirm:$false 6>$null + if (-not (Test-Path -LiteralPath $marker)) { throw 'winutil did not run after a hash match' } + } + finally { $script:PSP.IsAdmin = $savedAdmin } + } + Invoke-CommandProbe -Command 'harden' -Code { + $script:hardenTarget = $null + function Get-ExternalCommandPath { param([string]$CommandName) if ($CommandName -eq 'hss.exe') { 'C:\Tools\hss.exe' } } + function Start-Process { param([string]$FilePath) $script:hardenTarget = $FilePath } + harden -WhatIf + if ($script:hardenTarget) { throw 'harden launched under -WhatIf' } + harden -Confirm:$false + if ($script:hardenTarget -ne 'C:\Tools\hss.exe') { throw "unexpected launch target: $script:hardenTarget" } + } # Security and crypto $sha256 = $null @@ -830,7 +922,11 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { } # Developer - Invoke-CommandProbe -Command 'killport' -SkipReason 'Destructive process termination command' + Invoke-CommandProbe -Command 'killport' -Code { + # The local listener belongs to this process; -WhatIf must leave it running. + killport $localPort -WhatIf 6>$null 3>$null + if (-not (Get-Process -Id $PID -ErrorAction SilentlyContinue)) { throw 'killport stopped the test process' } + } 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 { @@ -846,7 +942,14 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { 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 'Remove-LockedItem' -Code { + $lockedProbe = Join-Path $workspace 'locked-probe.txt' + [System.IO.File]::WriteAllText($lockedProbe, 'x', $utf8NoBom) + Remove-LockedItem $lockedProbe -WhatIf 6>$null + if (-not (Test-Path -LiteralPath $lockedProbe)) { throw 'Remove-LockedItem -WhatIf deleted the file' } + Remove-LockedItem $lockedProbe -Confirm:$false 6>$null + if (Test-Path -LiteralPath $lockedProbe) { throw 'Remove-LockedItem did not delete an unlocked file' } + } 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" } @@ -857,7 +960,7 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { $pretty = prettyjson $jsonFile | Out-String if ($pretty -notmatch '"name"\s*:\s*"ci"') { throw 'prettyjson output missing expected key/value' } } - # LIVE NETWORK: POST $textFile to bin.christitus.com without prompting. + # The paste service is mocked unless -LiveNetwork -AllowPublicUpload; hb copies the URL to the clipboard. Invoke-CommandProbe -Command 'hb' -Code { hb $textFile -Confirm:$false | Out-Null } -SkipReason $clipboardSkipReason Invoke-CommandProbe -Command 'timer' -Code { timer { Start-Sleep -Milliseconds 5 } | Out-Null } Invoke-CommandProbe -Command 'watch' -SkipReason 'Infinite loop by design' @@ -909,8 +1012,23 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { 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)' + foreach ($fwCommand in 'fwallow', 'fwblock') { + Invoke-CommandProbe -Command $fwCommand -Code { + # New-NetFirewallRule is replaced, so no rule is created; the admin check is satisfied for the probe. + $script:fwRules = @() + $savedAdmin = $script:PSP.IsAdmin + $script:PSP.IsAdmin = $true + try { + function New-NetFirewallRule { param([string]$DisplayName, [string]$Direction, [string]$Action, [string]$Protocol, [int]$LocalPort) $script:fwRules += $Action } + & $fwCommand -Name 'PSP CI Probe' -Port 8443 -WhatIf + if ($script:fwRules.Count -ne 0) { throw "$fwCommand changed the firewall under -WhatIf" } + & $fwCommand -Name 'PSP CI Probe' -Port 8443 -Confirm:$false + $expectedAction = if ($fwCommand -eq 'fwallow') { 'Allow' } else { 'Block' } + if ($script:fwRules -notcontains $expectedAction) { throw "$fwCommand did not request an $expectedAction rule" } + } + finally { $script:PSP.IsAdmin = $savedAdmin } + } + } # Cybersec Invoke-CommandProbe -Command 'nscan' -SkipReason 'Requires nmap binary' @@ -929,7 +1047,7 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { } 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 'certcheck' -Code { certcheck 'example.com' | Out-Null } -SkipReason $liveSkipReason Invoke-CommandProbe -Command 'entropy' -Code { entropy $textFile | Out-Null } # Developer+ @@ -1143,8 +1261,18 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { 'Write-Utf8FileAtomic' 'Set-PspFeatureOverride' 'Get-LatestMainCommitSha' + 'ConvertFrom-JsonC' + 'Read-JsonFile' + 'Get-CombinedSha256' + 'Resolve-PspCommitSha' + 'Update-WindowsTerminalSettingsFile' + 'Undo-WindowsTerminalSettingsFile' + 'Copy-PspObject' + 'Merge-PspUserSettings' + 'ConvertTo-PspSortedNode' ) - $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ } + # Helpers named *-Psp* and *-Wt* are internal too (covered by tests/unit.ps1). + $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ -and $_ -notmatch '-(Psp|Wt)[A-Z]' } $coveredNames = @([string[]]$script:executedCommands + [string[]]$script:skippedCommands.Keys) | Sort-Object -Unique $missingFns = $commandFns | Where-Object { $coveredNames -notcontains $_ } @@ -1165,8 +1293,9 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { $allExports = @($commandFns + $aliasNames) | Sort-Object -Unique $execPct = if ($allExports.Count -gt 0) { [math]::Round(($script:executedCommands.Count / $allExports.Count) * 100) } else { 0 } - $detail = "functions=$($commandFns.Count), aliases=$($aliasNames.Count), executed=$($script:executedCommands.Count), skipped=$($script:skippedCommands.Count), network-soft-fails=$($script:networkSoftFails.Count), exec%=$execPct" + $detail = "functions=$($commandFns.Count), aliases=$($aliasNames.Count), executed=$($script:executedCommands.Count), skipped=$($script:skippedCommands.Count), network-soft-fails=$($script:networkSoftFails.Count), exec%=$execPct (minimum $MinimumExecutedPercent)" Write-Host " $detail" -ForegroundColor DarkGray + if ($execPct -lt $MinimumExecutedPercent) { throw "Only $execPct% of exports were executed (minimum $MinimumExecutedPercent%)" } } -SkipWhen { -not $script:installReady } -SkipReason 'Sandbox install failed' Invoke-TestCase -Name 'Uninstall profile from sandbox' -Code { @@ -1181,6 +1310,7 @@ Invoke-TestCase -Name 'Uninstall profile from sandbox' -Code { Set-Variable -Name PROFILE -Scope Global -Value $script:sandboxPs7Profile $env:CI = 'true' . $script:sandboxPs7Profile + . $PspPostLoadStubs Uninstall-Profile -RemoveUserData -Confirm:$false diff --git a/tests/lib/Sandbox.ps1 b/tests/lib/Sandbox.ps1 new file mode 100644 index 0000000..c01cd57 --- /dev/null +++ b/tests/lib/Sandbox.ps1 @@ -0,0 +1,148 @@ +# Shared helpers for the test scripts: disposable sandboxes that redirect every location the profile and setup.ps1 +# write to, fail-closed stubs for commands that would change the machine, and a guard against elevated runs. +# Dot-source this file: . (Join-Path $PSScriptRoot 'lib\Sandbox.ps1') + +# Return $true when the current process runs elevated. +function Test-PspTestElevated { + try { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } + catch { $false } +} + +# Stop unless the run is unelevated or the caller opted in; machine-wide scopes (fonts, HKLM, firewall) are writable only when elevated. +function Assert-PspTestSafety { + param([switch]$AllowElevated, [string]$ScriptName = 'This test script') + if ((Test-PspTestElevated) -and -not $AllowElevated) { + throw "$ScriptName refuses to run in an elevated shell: a test mistake could then change machine-wide fonts, registry or firewall settings. Run it from a normal shell, or pass -AllowElevated inside a disposable VM." + } +} + +# Create a disposable sandbox folder tree and return its paths. +function New-PspSandbox { + param([string]$Name = 'sandbox', [string]$Root = [System.IO.Path]::GetTempPath()) + $base = Join-Path $Root ('psp-test-{0}-{1}' -f $Name, [System.IO.Path]::GetRandomFileName()) + $sandbox = [PSCustomObject]@{ + Root = $base + Local = Join-Path $base 'Local' + Temp = Join-Path $base 'Temp' + Docs = Join-Path $base 'Documents' + Ps7Dir = Join-Path $base 'Documents\PowerShell' + Ps5Dir = Join-Path $base 'Documents\WindowsPowerShell' + Cache = Join-Path $base 'Local\PowerShellProfile' + WtDir = Join-Path $base 'Local\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' + Fonts = Join-Path $base 'Fonts' + Work = Join-Path $base 'Work' + Bin = Join-Path $base 'bin' + } + foreach ($dir in $sandbox.Local, $sandbox.Temp, $sandbox.Ps7Dir, $sandbox.Work, $sandbox.Bin) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + # Code that looks winget up as an application (Get-Command -CommandType Application) finds this instead of the real one; + # every call is logged to winget-calls.log next to it and fails. + $wingetStub = "@echo off`r`necho %*>>""%~dp0winget-calls.log""`r`necho Blocked by the test sandbox: winget %* 1>&2`r`nexit /b 1`r`n" + [System.IO.File]::WriteAllText((Join-Path $sandbox.Bin 'winget.cmd'), $wingetStub, [System.Text.Encoding]::ASCII) + $sandbox +} + +# Return the winget command lines that reached the sandbox's winget.cmd. +function Get-PspSandboxWingetCalls { + param([Parameter(Mandatory)][object]$Sandbox) + $log = Join-Path $Sandbox.Bin 'winget-calls.log' + if (Test-Path -LiteralPath $log) { @(Get-Content -LiteralPath $log) } else { @() } +} + +# Point LOCALAPPDATA, TEMP/TMP and $PROFILE into a sandbox and install the fail-closed stubs; call before loading the profile. +function Enter-PspSandbox { + param([Parameter(Mandatory)][object]$Sandbox) + if (-not (Get-Variable -Name PspSandboxSaved -Scope Global -ErrorAction Ignore)) { + $global:PspSandboxSaved = @{ + LOCALAPPDATA = $env:LOCALAPPDATA; TEMP = $env:TEMP; TMP = $env:TMP; CI = $env:CI; NO_COLOR = $env:NO_COLOR; PROFILE = $global:PROFILE; PATH = $env:PATH + } + } + $env:PATH = $Sandbox.Bin + ';' + $global:PspSandboxSaved.PATH + $env:LOCALAPPDATA = $Sandbox.Local + $env:TEMP = $Sandbox.Temp + $env:TMP = $Sandbox.Temp + $env:CI = 'true' + $global:PROFILE = Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1' + Install-PspTestStubs +} + +# Restore the environment saved by the first Enter-PspSandbox and remove the stubs. +function Exit-PspSandbox { + param([object]$Sandbox, [switch]$Keep) + $saved = Get-Variable -Name PspSandboxSaved -Scope Global -ValueOnly -ErrorAction Ignore + if ($saved) { + foreach ($name in 'LOCALAPPDATA', 'TEMP', 'TMP', 'CI', 'NO_COLOR', 'PATH') { Set-Item -Path "env:$name" -Value $saved[$name] -ErrorAction SilentlyContinue } + $global:PROFILE = $saved.PROFILE + Remove-Variable -Name PspSandboxSaved -Scope Global -ErrorAction SilentlyContinue + } + Remove-PspTestStubs + if ($Sandbox -and -not $Keep -and (Test-Path -LiteralPath $Sandbox.Root)) { + Remove-Item -LiteralPath $Sandbox.Root -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$script:PspTestStubNames = @( + 'winget', 'Install-Module', 'Uninstall-Module', 'Install-PSResource', 'Uninstall-PSResource', 'Update-Module', + 'Set-ExecutionPolicy', 'Start-Process', 'Remove-ItemProperty', 'Set-ItemProperty', 'New-ItemProperty', + 'New-NetFirewallRule', 'Remove-NetFirewallRule', 'Stop-Computer', 'Restart-Computer', 'Start-MpScan' +) + +# Define global functions that shadow commands which would change the machine; each one throws so a test cannot reach the real command. +function Install-PspTestStubs { + foreach ($name in $script:PspTestStubNames) { + $body = [scriptblock]::Create("throw ""Blocked by the test sandbox: $name `$args""") + Set-Item -Path "function:global:$name" -Value $body + } + # Registry writes are allowed only below the test key. + Set-Item -Path 'function:global:Remove-ItemProperty' -Value { + param([string]$Path, [string]$Name, [switch]$Force, $ErrorAction) + if ($Path -notlike 'HKCU:\SOFTWARE\PowerShellPerfectTest*') { throw "Blocked by the test sandbox: Remove-ItemProperty $Path" } + Microsoft.PowerShell.Management\Remove-ItemProperty -Path $Path -Name $Name -Force -ErrorAction SilentlyContinue + } +} + +# Remove the stubs installed by Install-PspTestStubs. +function Remove-PspTestStubs { + foreach ($name in $script:PspTestStubNames) { Remove-Item -Path "function:global:$name" -ErrorAction SilentlyContinue } +} + +# Dot-source right after loading the profile: replaces profile functions that reach outside the sandbox. +$script:PspPostLoadStubs = { + # oh-my-posh keeps its own cache outside LOCALAPPDATA\PowerShellProfile. + function Clear-OhMyPoshCaches { param([switch]$Quiet) } + # Reloading PATH from the registry would drop the sandbox's winget.cmd from the front of PATH. + function Update-SessionPathFromRegistry { } + # Font removal stays inside the sandbox. + if ($script:PSP) { + $script:PSP.TestFontScopes = @( + @{ Name = 'per-user'; Dir = (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts'); Reg = 'HKCU:\SOFTWARE\PowerShellPerfectTest\Fonts-NotPresent'; NeedsAdmin = $false } + ) + } +} + +# Return the source text of the named top-level functions of a script, for loading helpers without running the script. +function Get-PspFunctionSource { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string[]]$Name) + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { throw "$Path has $($parseErrors.Count) parse error(s)" } + $defined = @{} + foreach ($statement in $ast.EndBlock.Statements) { + if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) { $defined[$statement.Name] = $statement.Extent.Text } + } + foreach ($n in $Name) { + if (-not $defined.ContainsKey($n)) { throw "$Path does not define $n" } + $defined[$n] + } +} + +# Return the source text of a top-level variable assignment of a script (for example $script:CuratedFonts). +function Get-PspAssignmentSource { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Variable) + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors) + $hit = $ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and $_.Left.Extent.Text -eq $Variable } | Select-Object -First 1 + if (-not $hit) { throw "$Path does not assign $Variable" } + $hit.Extent.Text +} diff --git a/tests/lint.ps1 b/tests/lint.ps1 index 132a0c5..b25f370 100644 --- a/tests/lint.ps1 +++ b/tests/lint.ps1 @@ -1,17 +1,23 @@ -# Run the CI PSScriptAnalyzer rules locally while excluding test harnesses. +# Run the CI PSScriptAnalyzer check locally: the pinned version with PSScriptAnalyzerSettings.psd1, tests/ excluded. +# pwsh -NoProfile -File tests/lint.ps1 # installs the pinned PSScriptAnalyzer for the current user when missing +# pwsh -NoProfile -File tests/lint.ps1 -NoInstall # fail instead of installing +param([switch]$NoInstall) + +$ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot -# Pin the PSScriptAnalyzer version used by CI. -$pinnedPSSAVersion = '1.24.0' +# Keep in step with .github/workflows/ci.yml. +$pinnedPSSAVersion = '1.25.0' $have = Get-Module -ListAvailable -Name PSScriptAnalyzer | Where-Object { $_.Version -eq [version]$pinnedPSSAVersion } if (-not $have) { + if ($NoInstall) { throw "PSScriptAnalyzer $pinnedPSSAVersion is not installed." } 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') } +$settings = Join-Path $repoRoot 'PSScriptAnalyzerSettings.psd1' +$results = Get-ChildItem -LiteralPath $repoRoot -Filter *.ps1 -File | + ForEach-Object { Invoke-ScriptAnalyzer -Path $_.FullName -Settings $settings } if ($results) { $results | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize exit 1 -} else { - Write-Host 'PSScriptAnalyzer: clean' -ForegroundColor Green } +Write-Host "PSScriptAnalyzer $pinnedPSSAVersion`: clean" -ForegroundColor Green diff --git a/tests/locallab.ps1 b/tests/locallab.ps1 index 6052310..e0c8731 100644 --- a/tests/locallab.ps1 +++ b/tests/locallab.ps1 @@ -1,12 +1,13 @@ # Run the tracked local test and installation harness from the repository 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 # run all checks (sandboxed; nothing outside the repo changes) +# pwsh -NoProfile -File tests/locallab.ps1 -Install # checks + install this checkout live without tools (setup.ps1 -CiMode) +# pwsh -NoProfile -File tests/locallab.ps1 -FullInstall # checks + setup.ps1 -LocalRepo (winget tools, font, wizard prompts) +# pwsh -NoProfile -File tests/locallab.ps1 -Wizard # checks + setup.ps1 -LocalRepo -Wizard (implies -FullInstall) +# pwsh -NoProfile -File tests/locallab.ps1 -Functional # also run tests/ci-functional.ps1 (sandboxed, mocked network) # pwsh -NoProfile -File tests/locallab.ps1 -Restore # restore profile(s) from last backup +# The test steps refuse to run in an elevated shell unless -AllowElevated is passed (use that only in a disposable VM). [CmdletBinding(SupportsShouldProcess = $true)] param( @@ -18,7 +19,8 @@ param( [switch]$Install, [switch]$FullInstall, [switch]$Wizard, - [switch]$Restore + [switch]$Restore, + [switch]$AllowElevated ) # -Wizard implies -FullInstall (wizard only meaningful when driving setup.ps1) @@ -28,6 +30,7 @@ $ErrorActionPreference = 'Stop' # Resolve repoRoot as the parent of tests/. $repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) Set-Location $repoRoot +. (Join-Path $repoRoot 'tests\lib\Sandbox.ps1') $script:failures = @() $script:step = 0 @@ -75,38 +78,33 @@ if ($Restore) { exit 0 } +# Test steps change nothing outside their sandboxes, but an elevated mistake could reach machine-wide state. +if (-not ($SkipLint -and $SkipParse -and $SkipSmoke -and $SkipTest -and -not $Functional)) { + try { Assert-PspTestSafety -AllowElevated:$AllowElevated -ScriptName 'tests/locallab.ps1' } + catch { Write-Host $_.Exception.Message -ForegroundColor Red; exit 2 } +} +$elevatedArgs = if ($AllowElevated) { @('-AllowElevated') } else { @() } + # --- 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')) { +foreach ($f in @('Microsoft.PowerShell_profile.ps1', 'setup.ps1', 'setprofile.ps1', 'theme.json', 'terminal-config.json', 'tests/ci-functional.ps1', 'tests/unit.ps1', 'PSScriptAnalyzerSettings.psd1')) { if (Test-Path $f) { Ok $f } else { Fail $f 'missing'; } } -# --- 1. PSScriptAnalyzer (mirror CI) --- +# --- 1. PSScriptAnalyzer (same version and settings as 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' - ) - # Exclude test harnesses and untracked scratch scripts from source linting. - $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' } + Step 'PSScriptAnalyzer (tests/lint.ps1)' + # Never installs modules: without the pinned analyzer the step is skipped with the install command. + $lintOutput = & pwsh -NoProfile -NonInteractive -File tests/lint.ps1 -NoInstall 2>&1 + if ($LASTEXITCODE -eq 0) { Ok 'lint' 'clean' } + elseif (($lintOutput | Out-String) -match 'PSScriptAnalyzer [\d.]+ is not installed') { + Write-Host ' SKIP PSScriptAnalyzer 1.25.0 is not installed: Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser (or run tests/lint.ps1)' -ForegroundColor DarkGray + } + else { + $lintOutput | ForEach-Object { Write-Host " $_" } + Fail 'lint' "exit $LASTEXITCODE" } - catch { Fail 'lint' $_.Exception.Message } } # --- 2. PS5 parse-check (uses real powershell.exe) --- @@ -132,44 +130,31 @@ if (-not $SkipParse) { } } -# --- 3. Non-interactive smoke load --- +# --- 3. Non-interactive smoke load inside a sandbox (your profile_user.ps1 and plugins are not run) --- 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 } + Step 'Smoke-test non-interactive load (sandboxed)' + $smoke = "& { param(`$repo) . (Join-Path `$repo 'tests\lib\Sandbox.ps1'); `$sb = New-PspSandbox -Name 'locallab'; Enter-PspSandbox -Sandbox `$sb; try { `$WarningPreference = 'SilentlyContinue'; `$Error.Clear(); . (Join-Path `$repo 'Microsoft.PowerShell_profile.ps1'); if (`$Error.Count) { exit 1 } } finally { Exit-PspSandbox -Sandbox `$sb } } '$($repoRoot -replace "'", "''")'" + pwsh -NoProfile -NonInteractive -Command $smoke 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { Ok 'smoke' 'loaded without errors' } + else { Fail 'smoke' "exit $LASTEXITCODE" } } -# --- 4. Optional: test.ps1 local test suite --- +# --- 4. Optional: test.ps1 local test suite (includes tests/unit.ps1) --- 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 + $testOutput = & pwsh -NoProfile -File tests/test.ps1 -SkipPS5 @elevatedArgs 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 --- +# --- 5. Optional: ci-functional.ps1 (sandboxed, third-party endpoints mocked) --- 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 } - } + & pwsh -NoProfile -File tests/ci-functional.ps1 @elevatedArgs + if ($LASTEXITCODE -eq 0) { Ok 'tests/ci-functional.ps1' } + else { Fail 'tests/ci-functional.ps1' "exit $LASTEXITCODE" } } # --- 6. Install to live profile dirs (replaces GitHub version) --- @@ -205,76 +190,16 @@ if ($Install -or $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" } - - # Refresh cached JSON configuration after copying the 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 local terminal theme changes without downloads or tool installation. - 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 } - } + # -CiMode installs the profile, configs and Windows Terminal settings (recorded, so Uninstall-Profile reverts them) + # without winget tools, fonts, PowerShell Gallery modules or prompts. + Step 'setup.ps1 -LocalRepo -CiMode (profile, configs, WT settings; no tools)' + & './setup.ps1' -LocalRepo $repoRoot -CiMode -SkipWizard + if ($LASTEXITCODE -eq 0) { Ok 'setup.ps1 -CiMode' } else { Fail 'setup.ps1 -CiMode' "exit $LASTEXITCODE" } Write-Host ' PSReadLine colors apply on next pwsh launch.' -ForegroundColor DarkGray } } diff --git a/tests/rawhunt.ps1 b/tests/rawhunt.ps1 index ee142ad..a88913e 100644 --- a/tests/rawhunt.ps1 +++ b/tests/rawhunt.ps1 @@ -1,5 +1,20 @@ -# Exercise profile functions, lifecycle operations, caching, and configuration merging. +# Exercise profile functions, lifecycle operations, caching, and configuration merging against the REAL environment. +# Unlike tests/test.ps1 and tests/unit.ps1 this is not sandboxed: it loads the profile with your settings, calls live web +# services, publishes a probe file to a public paste (hb), overwrites the clipboard, and runs winget. Opt in explicitly: +# pwsh -NoProfile -File tests/rawhunt.ps1 -AllowSideEffects +param( + [switch]$AllowSideEffects, + [switch]$AllowElevated +) $ErrorActionPreference = 'Continue' +. (Join-Path $PSScriptRoot 'lib\Sandbox.ps1') +if (-not $AllowSideEffects) { + Write-Host 'tests/rawhunt.ps1 changes real state: it loads your profile_user.ps1 and plugins, uploads a file to a public paste,' -ForegroundColor Yellow + Write-Host 'overwrites the clipboard, calls third-party web services and runs winget. Re-run with -AllowSideEffects to proceed,' -ForegroundColor Yellow + Write-Host 'or use tests/test.ps1 and tests/unit.ps1, which are sandboxed.' -ForegroundColor Yellow + exit 2 +} +Assert-PspTestSafety -AllowElevated:$AllowElevated -ScriptName 'tests/rawhunt.ps1' $ok = 0; $fail = 0; $bugs = @() function T { @@ -75,16 +90,16 @@ $mergeFn = $ast.FindAll({ }, $true) | Select-Object -First 1 if ($mergeFn) { . ([scriptblock]::Create($mergeFn.Extent.Text)) } -# Extract setup.ps1 functions via AST +# Extract setup.ps1 functions and data via AST; the helpers it shares with the profile are already loaded from the profile. $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) +$setupFns = $setupAst.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] } 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)) } +foreach ($variable in '$EditorCandidates', '$script:CuratedFonts', '$script:CuratedSchemes', '$script:NerdFontVersion', '$script:OmpThemesRef', '$script:PinnedOmpThemes', '$script:SharedFunctionNames') { + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $setupPath -Variable $variable))) +} +$script:IsElevated = Test-PspTestElevated +$script:IsCiHost = $false # Workspace $ws = Join-Path $env:TEMP "psp-raw-$([System.IO.Path]::GetRandomFileName())" @@ -753,24 +768,24 @@ T 'Install-WingetPackage (already installed)' { } T 'Install-NerdFonts (detection only)' { - $r = Install-NerdFonts + # -WhatIf: detect without installing when the font is missing. + $r = Install-NerdFonts -WhatIf 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 +T 'Install-OhMyPoshTheme (real download, pinned hash)' { + $tmpLocal = Join-Path $ws 'omp-local' + New-Item -ItemType Directory $tmpLocal -Force | Out-Null + $origLocal = $env:LOCALAPPDATA + $env:LOCALAPPDATA = $tmpLocal 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' + $r = Install-OhMyPoshTheme -ThemeName 'pure' -ThemeUrl (Get-OmpThemeUrl -Name 'pure') -Sha256 $script:PinnedOmpThemes['pure'] + if ($r -ne $true) { throw "Expected true, got $r (pinned hash no longer matches upstream?)" } + $themeFile = Join-Path (Get-PspCacheDir) 'pure.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" } + if (-not (Read-JsonFile $themeFile)) { throw "Theme file not valid JSON" } } - finally { $script:configCachePath = $origConfigCache } + finally { $env:LOCALAPPDATA = $origLocal } } T 'EditorCandidates valid' { @@ -780,7 +795,7 @@ T 'EditorCandidates valid' { } } -T 'Merge-JsonObject from setup.ps1' { +T 'Merge-JsonObject (shared with 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" } @@ -1247,39 +1262,16 @@ T 'Update-Tools: ProfileTools drive data' { } } -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" } +T 'setup.ps1 copies of pre-bundle helpers match the profile' { + foreach ($name in 'Write-PspHost', 'Invoke-DownloadWithRetry', 'Get-CombinedSha256', 'Resolve-PspCommitSha', 'Get-PspReleaseChecksums') { + $a = (Get-PspFunctionSource -Path $setupPath -Name $name) -replace "`r`n", "`n" + $b = (Get-PspFunctionSource -Path $profilePath -Name $name) -replace "`r`n", "`n" + if ($a -cne $b) { throw "$name differs between setup.ps1 and the profile" } + } +} + +T 'setup.ps1 shared helpers exist in the profile' { + $null = Get-PspFunctionSource -Path $profilePath -Name $script:SharedFunctionNames } # ##################################################################### diff --git a/tests/smoke.ps1 b/tests/smoke.ps1 new file mode 100644 index 0000000..fc165a7 --- /dev/null +++ b/tests/smoke.ps1 @@ -0,0 +1,54 @@ +# Load the profile non-interactively inside a sandbox in fresh processes of the running edition, fail on any load error, +# and fail when the median load time exceeds the budget (catches startup regressions). +# pwsh -NoProfile -File tests/smoke.ps1 +# powershell -NoProfile -File tests/smoke.ps1 -BudgetMs 6000 +param( + [ValidateRange(1, 10)][int]$Runs = 3, + [int]$BudgetMs = 4000 +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$exe = (Get-Process -Id $PID).Path +$child = @' +param([string]$RepoRoot) +. (Join-Path $RepoRoot 'tests\lib\Sandbox.ps1') +$sb = New-PspSandbox -Name 'smoke' +Enter-PspSandbox -Sandbox $sb +try { + $WarningPreference = 'SilentlyContinue' + $Error.Clear() + . (Join-Path $RepoRoot 'Microsoft.PowerShell_profile.ps1') + foreach ($e in $Error) { Write-Output ("LOADERROR {0}" -f ($e.ToString() -replace '\s+', ' ')) } + Write-Output ("LOADTIME {0}" -f $script:PSP.LoadTimeMs) +} +finally { Exit-PspSandbox -Sandbox $sb } +'@ +$childPath = Join-Path ([System.IO.Path]::GetTempPath()) ('psp-child-smoke-' + [System.IO.Path]::GetRandomFileName() + '.ps1') +[System.IO.File]::WriteAllText($childPath, $child, [System.Text.UTF8Encoding]::new($false)) +$times = @() +$failed = $false +try { + for ($i = 1; $i -le $Runs; $i++) { + $out = @(& $exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $childPath -RepoRoot $repoRoot 2>&1 | ForEach-Object { "$_" }) + $loadErrors = @($out | Where-Object { $_ -like 'LOADERROR *' }) + $timeLine = $out | Where-Object { $_ -like 'LOADTIME *' } | Select-Object -Last 1 + if ($LASTEXITCODE -ne 0 -or $loadErrors.Count -gt 0 -or -not $timeLine) { + $failed = $true + Write-Host "Run ${i}: the profile did not load cleanly (exit $LASTEXITCODE)" -ForegroundColor Red + $out | ForEach-Object { Write-Host " $_" } + break + } + $ms = [int]($timeLine -replace '^LOADTIME\s+', '') + $times += $ms + Write-Host "Run ${i}: loaded in $ms ms" + } +} +finally { Remove-Item -LiteralPath $childPath -Force -ErrorAction SilentlyContinue } +if ($failed) { exit 1 } +$median = ($times | Sort-Object)[[int][Math]::Floor(($times.Count - 1) / 2)] +Write-Host ("PowerShell {0}: median load {1} ms (budget {2} ms)" -f $PSVersionTable.PSVersion, $median, $BudgetMs) -ForegroundColor Cyan +if ($median -gt $BudgetMs) { + Write-Host 'Load time is over budget. Find the slow step with Measure-Command or a profiler before raising the budget.' -ForegroundColor Red + exit 1 +} diff --git a/tests/test.ps1 b/tests/test.ps1 index 531f3dd..e8e038e 100644 --- a/tests/test.ps1 +++ b/tests/test.ps1 @@ -1,11 +1,18 @@ -# Run CI-equivalent and local profile checks with optional -SkipPS5. +# Run CI-equivalent and local profile checks. Every step that loads the profile or runs setup.ps1 does so inside a +# disposable sandbox (LOCALAPPDATA, TEMP and $PROFILE redirected, machine-changing commands blocked). +# pwsh -NoProfile -File tests/test.ps1 # from a normal (non-elevated) shell +# pwsh -NoProfile -File tests/test.ps1 -SkipPS5 # without Windows PowerShell 5.1 param( - [switch]$SkipPS5 # Skip PS5 parse check if powershell.exe is unavailable + [switch]$SkipPS5, # Skip PS5 parse check if powershell.exe is unavailable + # Elevated runs are refused because a test mistake could then change machine-wide state; use only in a disposable VM. + [switch]$AllowElevated ) $ErrorActionPreference = 'Stop' # Use the platform temp directory when TEMP is unavailable. if (-not $env:TEMP) { $env:TEMP = [System.IO.Path]::GetTempPath() } +. (Join-Path $PSScriptRoot 'lib\Sandbox.ps1') +Assert-PspTestSafety -AllowElevated:$AllowElevated -ScriptName 'tests/test.ps1' # Resolve repoRoot as the parent of tests/. $repoRoot = Split-Path -Parent $PSScriptRoot $passed = 0 @@ -14,13 +21,10 @@ $skipped = 0 $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() # Clean test sandboxes on terminating errors and 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:TestArtifactPatterns = @('psp-test-*', 'psp-child-*') $script:SweepTestArtifacts = { foreach ($pat in $script:TestArtifactPatterns) { - Get-ChildItem -LiteralPath $env:TEMP -Filter $pat -Force -ErrorAction SilentlyContinue | + Get-ChildItem -LiteralPath ([System.IO.Path]::GetTempPath()) -Filter $pat -Force -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } } } @@ -28,7 +32,7 @@ Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action $script:SweepT trap { Write-Host '' Write-Host ('Aborted: {0}' -f $_.Exception.Message) -ForegroundColor Yellow - Write-Host 'Sweeping sandbox artifacts in $env:TEMP ...' -ForegroundColor Yellow + Write-Host 'Sweeping sandbox artifacts ...' -ForegroundColor Yellow & $script:SweepTestArtifacts break } @@ -53,6 +57,42 @@ function Write-Result { } } +# Run a script body in a child process whose first lines enter a fresh sandbox; returns its output lines and exit code. +# The body sees $RepoRoot, $ProfileSource and $Sandbox and runs with the profile not yet loaded. +function Invoke-SandboxChild { + param([Parameter(Mandatory)][string]$Body, [string]$Exe = 'pwsh', [string[]]$ExtraArgs = @()) + $childScript = Join-Path ([System.IO.Path]::GetTempPath()) ("psp-child-{0}.ps1" -f [System.IO.Path]::GetRandomFileName()) + $prelude = @' +param([string]$RepoRoot) +$ErrorActionPreference = 'Stop' +. (Join-Path $RepoRoot 'tests\lib\Sandbox.ps1') +$ProfileSource = Join-Path $RepoRoot 'Microsoft.PowerShell_profile.ps1' +$Sandbox = New-PspSandbox -Name 'child' +Enter-PspSandbox -Sandbox $Sandbox +try { +'@ + $postlude = @' + +} +finally { Exit-PspSandbox -Sandbox $Sandbox } +'@ + [System.IO.File]::WriteAllText($childScript, ($prelude + "`r`n" + $Body + "`r`n" + $postlude), [System.Text.UTF8Encoding]::new($false)) + try { + $output = & $Exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $childScript -RepoRoot $repoRoot @ExtraArgs 2>&1 + [PSCustomObject]@{ Output = @($output | ForEach-Object { "$_" }); ExitCode = $LASTEXITCODE } + } + finally { Remove-Item -LiteralPath $childScript -Force -ErrorAction SilentlyContinue } +} + +# Print child output with colors matched to its status prefixes. +function Show-ChildOutput { + param([string[]]$Lines) + foreach ($line in $Lines) { + $color = if ($line -match '^\s+(OK|PASS)') { 'Green' } elseif ($line -match 'ASSERT:|^\s+FAIL') { 'Red' } elseif ($line -match '^\s+(WARN|NET)') { 'Yellow' } elseif ($line -match '^\s+SKIP') { 'DarkGray' } else { 'White' } + Write-Host " $line" -ForegroundColor $color + } +} + Write-Host '' Write-Host '========== PowerShellPerfect Full Test Suite ==========' -ForegroundColor Cyan Write-Host '' @@ -65,6 +105,7 @@ $srcAll = Get-ChildItem -Path $repoRoot -Recurse -Include *.ps1,*.json,*.md,*.ym $profilePath = Join-Path $repoRoot 'Microsoft.PowerShell_profile.ps1' $setupPath = Join-Path $repoRoot 'setup.ps1' +$hasPs5 = -not $SkipPS5 -and [bool](Get-Command powershell.exe -ErrorAction SilentlyContinue) # ===================================================================== # INSTALL: fresh install sandbox @@ -73,163 +114,40 @@ Write-Host '--- Install: fresh setup sandbox ---' -ForegroundColor Magenta Write-Host '' # ------------------------------------------------------- -# 1. Fresh install sandbox (simulates setup.ps1 on clean system) +# 1. Fresh install sandbox (setup.ps1 -LocalRepo -CiMode against a disposable sandbox, no network) # ------------------------------------------------------- -Write-Host '[1/26] Fresh install sandbox' -ForegroundColor Cyan +Write-Host '[1/27] 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' - + $r = Invoke-SandboxChild -Body @' $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' +New-Item -ItemType Directory -Path $Sandbox.WtDir -Force | Out-Null +$wtPath = Join-Path $Sandbox.WtDir 'settings.json' +[System.IO.File]::WriteAllText($wtPath, '{"profiles":{"defaults":{"font":{"face":"Consolas"}},"list":[]},"schemes":[],"actions":[]}', [System.Text.UTF8Encoding]::new($false)) +# No network: downloads fail as if offline (the Oh My Posh theme download is optional). +function global:Invoke-RestMethod { param([string]$Uri) throw "offline (test sandbox): $Uri" } +& (Join-Path $RepoRoot 'setup.ps1') -LocalRepo $RepoRoot -CiMode -SkipWizard *> $null +Remove-Item -Path function:global:Invoke-RestMethod -ErrorAction SilentlyContinue +if ($LASTEXITCODE -ne 0) { $errors += "setup.ps1 exit code $LASTEXITCODE" } +foreach ($p in (Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1'), (Join-Path $Sandbox.Ps5Dir 'Microsoft.PowerShell_profile.ps1'), (Join-Path $Sandbox.Ps7Dir 'profile_user.ps1')) { + if (-not (Test-Path -LiteralPath $p)) { $errors += "missing $p" } +} +foreach ($f in 'theme.json', 'terminal-config.json', 'user-settings.json', 'installed.json', 'wt-changes.json') { + if (-not (Test-Path -LiteralPath (Join-Path $Sandbox.Cache $f))) { $errors += "cache is missing $f" } +} +$wt = Get-Content -LiteralPath $wtPath -Raw | ConvertFrom-Json +if ($wt.profiles.defaults.font.face -ne 'CaskaydiaCove NF') { $errors += 'Windows Terminal defaults not applied' } +Write-Host ' OK setup.ps1 installed into the sandbox' +. (Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1') +. $PspPostLoadStubs +if (-not (Get-Command Show-Help -ErrorAction SilentlyContinue)) { $errors += 'installed profile did not load' } +else { Write-Host ' OK installed profile loads' } +if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } '@ - [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' } + Show-ChildOutput $r.Output + if ($r.ExitCode -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 @@ -239,76 +157,51 @@ Write-Host '--- CI: lint ---' -ForegroundColor Magenta Write-Host '' # ------------------------------------------------------- -# 2. PSScriptAnalyzer +# 2. PSScriptAnalyzer (same version and settings as CI) # ------------------------------------------------------- -Write-Host '[2/26] PSScriptAnalyzer' -ForegroundColor Cyan +Write-Host '[2/27] PSScriptAnalyzer' -ForegroundColor Cyan try { - if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { - Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.24.0 -Force -Scope CurrentUser + # The gate never installs modules; without the pinned analyzer the step is skipped with the install command. + $lintOutput = & pwsh -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'lint.ps1') -NoInstall 2>&1 + if ($LASTEXITCODE -ne 0 -and (($lintOutput | Out-String) -match 'PSScriptAnalyzer [\d.]+ is not installed')) { + Write-Result 'PSScriptAnalyzer' 'SKIP' 'PSScriptAnalyzer 1.25.0 is not installed: Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser (or run tests/lint.ps1, which installs it)' } - $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)" + elseif ($LASTEXITCODE -ne 0) { + $lintOutput | ForEach-Object { Write-Host " $_" } + Write-Result 'PSScriptAnalyzer' 'FAIL' 'warnings or errors (see above)' } else { Write-Result 'PSScriptAnalyzer' 'PASS' } } catch { Write-Result 'PSScriptAnalyzer' 'FAIL' $_.Exception.Message } # ------------------------------------------------------- -# 3. Smoke test (pwsh, non-interactive) -# ------------------------------------------------------- -Write-Host '[3/26] Smoke test (pwsh)' -ForegroundColor Cyan -if ($env:OS -ne 'Windows_NT') { - # Run the Windows-targeted profile smoke test only on Windows. - Write-Result 'Smoke test (pwsh)' 'SKIP' 'non-Windows host (profile is Windows-targeted)' -} -else { - try { - $env:CI = 'true' - # Scan non-terminating errors in addition to the smoke-test exit code. - $smoke = pwsh -NonInteractive -NoProfile -Command "`$WarningPreference='SilentlyContinue'; . '$profilePath'" 2>&1 - if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } - $smokeErrors = @($smoke | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) - if ($smokeErrors.Count -gt 0) { throw "$($smokeErrors.Count) error(s) during load: $($smokeErrors[0])" } - Write-Result 'Smoke test (pwsh)' 'PASS' - } - catch { Write-Result 'Smoke test (pwsh)' 'FAIL' $_.Exception.Message } - finally { $env:CI = $null } -} - -# ------------------------------------------------------- -# 4. Smoke test (PS5, non-interactive) +# 3./4. Smoke test (non-interactive load in a sandbox, both editions) # ------------------------------------------------------- -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 { +$smokeBody = @' +$Error.Clear() +$WarningPreference = 'SilentlyContinue' +. $ProfileSource +if ($Error.Count -gt 0) { $Error | ForEach-Object { Write-Host "ASSERT: load error: $_" }; exit 1 } +Write-Host (" OK loaded in {0} ms" -f $script:PSP.LoadTimeMs) +'@ +foreach ($edition in @(@{ Step = '[3/27]'; Name = 'Smoke test (pwsh)'; Exe = 'pwsh' }, @{ Step = '[4/27]'; Name = 'Smoke test (PS5)'; Exe = 'powershell.exe' })) { + Write-Host "$($edition.Step) $($edition.Name)" -ForegroundColor Cyan + if ($env:OS -ne 'Windows_NT') { Write-Result $edition.Name 'SKIP' 'non-Windows host (profile is Windows-targeted)'; continue } + if ($edition.Exe -eq 'powershell.exe' -and -not $hasPs5) { Write-Result $edition.Name 'SKIP' 'powershell.exe not available or -SkipPS5'; continue } try { - $escaped = $profilePath -replace "'", "''" - $ps5Smoke = powershell.exe -NoProfile -Command "`$env:CI = 'true'; `$WarningPreference = 'SilentlyContinue'; . '$escaped'" 2>&1 - if ($LASTEXITCODE -ne 0) { throw "Exit code: $LASTEXITCODE" } - $ps5Errors = @($ps5Smoke | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) - if ($ps5Errors.Count -gt 0) { throw "$($ps5Errors.Count) error(s) during load: $($ps5Errors[0])" } - Write-Result 'Smoke test (PS5)' 'PASS' + $r = Invoke-SandboxChild -Body $smokeBody -Exe $edition.Exe + Show-ChildOutput $r.Output + if ($r.ExitCode -ne 0) { throw "load reported errors (exit $($r.ExitCode))" } + Write-Result $edition.Name 'PASS' } - catch { Write-Result 'Smoke test (PS5)' 'FAIL' $_.Exception.Message } + catch { Write-Result $edition.Name '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-Host '[5/27] PS5 parse check' -ForegroundColor Cyan +if (-not $hasPs5) { Write-Result 'PS5 parse check' 'SKIP' 'powershell.exe not available or -SkipPS5' } else { @@ -330,7 +223,7 @@ else { # ------------------------------------------------------- # 6. Hardcoded user paths # ------------------------------------------------------- -Write-Host '[6/26] Hardcoded paths' -ForegroundColor Cyan +Write-Host '[6/27] Hardcoded paths' -ForegroundColor Cyan $pathPatterns = @('C:\\Users\\', 'C:/Users/', '/home/', '\\\\Users\\\\') $pathFinds = @() foreach ($file in $srcPs1) { @@ -352,7 +245,7 @@ else { Write-Result 'Hardcoded paths' 'PASS' } # ------------------------------------------------------- # 7. Non-ASCII characters # ------------------------------------------------------- -Write-Host '[7/26] Non-ASCII characters' -ForegroundColor Cyan +Write-Host '[7/27] Non-ASCII characters' -ForegroundColor Cyan $asciiFinds = @() foreach ($file in $srcPs1) { $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue @@ -371,9 +264,9 @@ else { Write-Result 'Non-ASCII characters' 'PASS' } # ------------------------------------------------------- # 8. UTF-8 BOM # ------------------------------------------------------- -Write-Host '[8/26] UTF-8 BOM' -ForegroundColor Cyan +Write-Host '[8/27] UTF-8 BOM' -ForegroundColor Cyan $bomFinds = @() -$bomFiles = Get-ChildItem -Path $repoRoot -Recurse -Include *.ps1,*.json | +$bomFiles = Get-ChildItem -Path $repoRoot -Recurse -Include *.ps1,*.psd1,*.json | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne $selfExclude } foreach ($file in $bomFiles) { $bytes = [System.IO.File]::ReadAllBytes($file.FullName) @@ -390,7 +283,7 @@ 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 +Write-Host '[9/27] Set-Content Encoding check' -ForegroundColor Cyan $scFinds = @() foreach ($file in $srcPs1) { $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue @@ -409,7 +302,7 @@ else { Write-Result 'Set-Content UTF8' 'PASS' } # ------------------------------------------------------- # 10. Secrets scan # ------------------------------------------------------- -Write-Host '[10/26] Secrets scan' -ForegroundColor Cyan +Write-Host '[10/27] Secrets scan' -ForegroundColor Cyan $secretPatterns = @( '(?i)(api[_-]?key|apikey)\s*[:=]\s*[''"][A-Za-z0-9+/=]{16,}[''"]' '(?i)(secret|token|password)\s*[:=]\s*[''"][^''"]{8,}[''"]' @@ -446,7 +339,7 @@ Write-Host '' # ------------------------------------------------------- # 11. JSON config validation # ------------------------------------------------------- -Write-Host '[11/26] JSON config validation' -ForegroundColor Cyan +Write-Host '[11/27] JSON config validation' -ForegroundColor Cyan $jsonErrors = 0 foreach ($jf in @('theme.json', 'terminal-config.json')) { $jfPath = Join-Path $repoRoot $jf @@ -459,19 +352,22 @@ if ($jsonErrors -gt 0) { Write-Result 'JSON configs' 'FAIL' "$jsonErrors file(s) else { Write-Result 'JSON configs' 'PASS' } # ------------------------------------------------------- -# 12. Config schema (required keys) +# 12. Config schema (required keys and pins) # ------------------------------------------------------- -Write-Host '[12/26] Config schema' -ForegroundColor Cyan +Write-Host '[12/27] 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 ($theme.theme.url -match '/main/') { $schemaErrors += 'theme.json: theme.url follows a branch instead of a release tag' } +if ($theme.theme.sha256 -notmatch '^[A-Fa-f0-9]{64}$') { $schemaErrors += 'theme.json: theme.sha256 missing or malformed' } 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 ($terminal.fontInstall.version -notmatch '^\d+\.\d+\.\d+$') { $schemaErrors += 'terminal-config.json: fontInstall.version missing or not x.y.z' } +if ($terminal.fontInstall.sha256 -notmatch '^[A-Fa-f0-9]{64}$') { $schemaErrors += 'terminal-config.json: fontInstall.sha256 missing or malformed' } if ($schemaErrors) { $schemaErrors | ForEach-Object { Write-Host " $_" -ForegroundColor Red } Write-Result 'Config schema' 'FAIL' "$($schemaErrors.Count) issue(s)" @@ -479,57 +375,44 @@ if ($schemaErrors) { else { Write-Result 'Config schema' 'PASS' } # ------------------------------------------------------- -# 13. setup.ps1 dry-run (required function definitions) +# 13. setup.ps1 structure (functions, copies identical to the profile, verification before changes) # ------------------------------------------------------- -Write-Host '[13/26] setup.ps1 function definitions' -ForegroundColor Cyan +Write-Host '[13/27] setup.ps1 structure' -ForegroundColor Cyan try { + $problems = @() $setupContent = Get-Content $setupPath -Raw - $requiredFunctions = @( - 'Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', - 'Install-WingetPackage', 'Merge-JsonObject', 'Select-PreferredEditor', - 'Invoke-DownloadWithRetry', 'Remove-SafeTempDirectory', - 'Update-SessionPathFromRegistry' - ) - $missingFns = @() - foreach ($fn in $requiredFunctions) { - if ($setupContent -notmatch "function\s+$fn\b") { $missingFns += $fn } - } - $remotePreflightStart = Select-String -Path $setupPath -Pattern 'For remote installs, verify/download' | Select-Object -First 1 - $remotePreflight = Select-String -Path $setupPath -Pattern '^\s*Initialize-RemoteInstallBundle\s*$' | - Where-Object { $remotePreflightStart -and $_.LineNumber -gt $remotePreflightStart.LineNumber } | - Select-Object -First 1 - $policyMutation = Select-String -Path $setupPath -Pattern 'Set-ExecutionPolicy RemoteSigned' | Select-Object -First 1 - if (-not $remotePreflight) { $missingFns += 'remote bundle preflight call' } - elseif ($policyMutation -and $remotePreflight.LineNumber -gt $policyMutation.LineNumber) { - $missingFns += 'remote bundle preflight before mutations' - } - if ($setupContent -match [regex]::Escape('$env:PATH = [System.Environment]::GetEnvironmentVariable(''PATH'', ''Machine'') + '';'' + [System.Environment]::GetEnvironmentVariable(''PATH'', ''User'')')) { - $missingFns += 'safe PATH registry refresh' - } - if ($missingFns) { - $missingFns | ForEach-Object { Write-Host " Missing: $_" -ForegroundColor Red } - Write-Result 'setup.ps1 functions' 'FAIL' "$($missingFns.Count) missing" - } - else { Write-Result 'setup.ps1 functions' 'PASS' } -} -catch { Write-Result 'setup.ps1 functions' 'FAIL' $_.Exception.Message } + foreach ($fn in 'Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', 'Install-WingetPackage', 'Select-PreferredEditor', + 'Remove-SafeTempDirectory', 'Initialize-InstallBundle', 'Save-WizardChoices', 'Resolve-WizardState', 'Invoke-SetupInstall') { + if ($setupContent -notmatch "function\s+$fn\b") { $problems += "missing function $fn" } + } + # Helpers setup.ps1 needs before the bundle is verified are copies; they must match the profile exactly. + foreach ($fn in 'Write-PspHost', 'Invoke-DownloadWithRetry', 'Get-CombinedSha256', 'Resolve-PspCommitSha', 'Get-PspReleaseChecksums') { + $a = (Get-PspFunctionSource -Path $setupPath -Name $fn) -replace "`r`n", "`n" + $b = (Get-PspFunctionSource -Path $profilePath -Name $fn) -replace "`r`n", "`n" + if ($a -cne $b) { $problems += "$fn in setup.ps1 differs from the profile" } + } + # Everything else setup.ps1 loads from the verified profile must exist there. + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $setupPath -Variable '$script:SharedFunctionNames'))) + try { $null = Get-PspFunctionSource -Path $profilePath -Name $script:SharedFunctionNames } + catch { $problems += $_.Exception.Message } + # The main flow verifies the bundle before it installs anything. + $verifyAt = $setupContent.IndexOf('$script:Bundle = Initialize-InstallBundle') + $installAt = $setupContent.IndexOf('$installed = Invoke-SetupInstall') + if ($verifyAt -lt 0 -or $installAt -lt 0 -or $verifyAt -gt $installAt) { $problems += 'bundle verification does not come before installation' } + if ($problems) { + $problems | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + Write-Result 'setup.ps1 structure' 'FAIL' "$($problems.Count) problem(s)" + } + else { Write-Result 'setup.ps1 structure' 'PASS' } +} +catch { Write-Result 'setup.ps1 structure' 'FAIL' $_.Exception.Message } # ------------------------------------------------------- -# 14. Merge-JsonObject unit tests +# 14. Merge-JsonObject tests (the profile's own function) # ------------------------------------------------------- -Write-Host '[14/26] Merge-JsonObject tests' -ForegroundColor Cyan +Write-Host '[14/27] 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 - } - } - } + . ([scriptblock]::Create((Get-PspFunctionSource -Path $profilePath -Name 'Merge-JsonObject'))) # Flat merge $b = [PSCustomObject]@{ a = 1; b = 2 } @@ -546,54 +429,31 @@ try { Merge-JsonObject $b ([PSCustomObject]@{ theme = [PSCustomObject]@{ name = 'pure' } }) if ($b.theme.name -ne 'pure') { throw 'Object replacement failed' } + # A null base is a caller bug and must not be ignored. + $threw = $false + try { Merge-JsonObject $null ([PSCustomObject]@{ a = 1 }) } catch { $threw = $true } + if (-not $threw) { throw 'Null base accepted' } + 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) +# 15. Regression tests (tests/unit.ps1, both editions) # ------------------------------------------------------- -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 }) +Write-Host '[15/27] Regression tests (tests/unit.ps1)' -ForegroundColor Cyan +$unitArgs = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $PSScriptRoot 'unit.ps1')) +if ($AllowElevated) { $unitArgs += '-AllowElevated' } +foreach ($exe in @('pwsh') + @(if ($hasPs5) { 'powershell.exe' })) { + try { + $unitOutput = & $exe @unitArgs 2>&1 | ForEach-Object { "$_" } + $unitExit = $LASTEXITCODE + Show-ChildOutput @($unitOutput | Where-Object { $_ -match '^\s+(PASS|FAIL)|passed|^\s{8}' }) + if ($unitExit -ne 0) { throw "tests/unit.ps1 failed under $exe" } + Write-Result "Regression tests ($exe)" 'PASS' } - $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 "Regression tests ($exe)" 'FAIL' $_.Exception.Message } } -catch { Write-Result 'WT merge mock' 'FAIL' $_.Exception.Message } # ===================================================================== # LOCAL-ONLY: profile-level validation @@ -605,37 +465,39 @@ Write-Host '' # ------------------------------------------------------- # 16. ProfileTools metadata (every entry has required fields) # ------------------------------------------------------- -Write-Host '[16/26] ProfileTools metadata' -ForegroundColor Cyan +Write-Host '[16/27] 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" } + $r = Invoke-SandboxChild -Body @' +$WarningPreference = 'SilentlyContinue' +. $ProfileSource +. $PspPostLoadStubs +$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 ($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)" } +if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } +Write-Host " OK $($script:ProfileTools.Count) tools validated" +'@ + Show-ChildOutput $r.Output + if ($r.ExitCode -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 +Write-Host '[17/27] Key functions exist' -ForegroundColor Cyan try { - $env:CI = 'true' - $output = pwsh -NonInteractive -NoProfile -Command @" -. '$profilePath' -`$expected = @( + $r = Invoke-SandboxChild -Body @' +$WarningPreference = 'SilentlyContinue' +. $ProfileSource +. $PspPostLoadStubs +$expected = @( 'Update-Profile','Update-PowerShell','Update-Tools','Uninstall-Profile', 'Clear-ProfileCache','Clear-Cache','Show-Help','Resolve-PreferredEditor', 'edit','Edit-Profile','Invoke-DownloadWithRetry', @@ -654,482 +516,260 @@ try { 'ep','su' ) if (Get-Command ssh -ErrorAction SilentlyContinue) { - `$expected += @('Copy-SshKey','keygen','ssh-copy-key') + $expected += @('Copy-SshKey','keygen','ssh-copy-key') } if (Get-Command docker -ErrorAction SilentlyContinue) { - `$expected += @('dps','dpa','dimg','dlogs','dex','dstop','dprune') + $expected += @('dps','dpa','dimg','dlogs','dex','dstop','dprune') } -`$missing = @() -foreach (`$fn in `$expected) { - if (-not (Get-Command `$fn -ErrorAction SilentlyContinue)) { `$missing += `$fn } +$missing = @() +foreach ($fn in $expected) { + if (-not (Get-Command $fn -ErrorAction SilentlyContinue)) { $missing += $fn } } -if (`$missing) { - `$missing | ForEach-Object { Write-Host "Missing: `$_" } +if ($missing) { + $missing | ForEach-Object { Write-Host "ASSERT: missing $_" } exit 1 } -Write-Host "OK: `$(`$expected.Count) functions verified" -"@ - if ($LASTEXITCODE -ne 0) { throw "Missing functions (see above)" } +Write-Host " OK $($expected.Count) functions verified" +'@ + Show-ChildOutput $r.Output + if ($r.ExitCode -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) +# 18. Uninstall-Profile -All -WhatIf (all phases produce output, nothing changes) # ------------------------------------------------------- -Write-Host '[18/26] Uninstall-Profile -WhatIf' -ForegroundColor Cyan +Write-Host '[18/27] 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' } - # Require core cleanup output while allowing Windows Terminal restoration to be absent. - $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" + $r = Invoke-SandboxChild -Body @' +$WarningPreference = 'SilentlyContinue' +. $ProfileSource +. $PspPostLoadStubs +$utf8 = [System.Text.UTF8Encoding]::new($false) +New-Item -ItemType Directory -Path $Sandbox.Cache -Force | Out-Null +foreach ($f in 'theme.json', 'omp-init.ps1') { [System.IO.File]::WriteAllText((Join-Path $Sandbox.Cache $f), '{}', $utf8) } +[System.IO.File]::WriteAllText((Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1'), '# profile', $utf8) +# WhatIf messages go straight to the host, so capture them through a transcript. +$log = Join-Path $Sandbox.Work 'whatif.log' +Start-Transcript -LiteralPath $log | Out-Null +Uninstall-Profile -All -WhatIf +Stop-Transcript | Out-Null +$whatIf = @(Get-Content -LiteralPath $log | Where-Object { $_ -match 'What if' }) +$errors = @() +if ($whatIf.Count -eq 0) { $errors += 'no WhatIf output' } +foreach ($phase in 'Remove cache', 'Remove profile file') { if (-not ($whatIf | Where-Object { $_ -match $phase })) { $errors += "missing phase: $phase" } } +foreach ($p in (Join-Path $Sandbox.Cache 'theme.json'), (Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1')) { if (-not (Test-Path -LiteralPath $p)) { $errors += "WhatIf removed $p" } } +if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } +Write-Host " OK $($whatIf.Count) planned action(s), nothing changed" +'@ + Show-ChildOutput $r.Output + if ($r.ExitCode -ne 0) { throw 'WhatIf assertions failed' } + Write-Result 'Uninstall-Profile -WhatIf' 'PASS' } catch { Write-Result 'Uninstall-Profile -WhatIf' 'FAIL' $_.Exception.Message } -finally { $env:CI = $null } # ------------------------------------------------------- -# 19. Uninstall-Profile sandbox: core (real file deletion) +# 19. Uninstall-Profile sandbox: core (real file deletion inside the sandbox) # ------------------------------------------------------- -Write-Host '[19/26] Uninstall sandbox: core' -ForegroundColor Cyan +Write-Host '[19/27] 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' + $r = Invoke-SandboxChild -Body @' +$WarningPreference = 'SilentlyContinue' . $ProfileSource - -# 3. Create sandbox directory tree -$sb = Join-Path $env:TEMP "psp-sandbox-$([System.IO.Path]::GetRandomFileName())" - -$wtLocal = Join-Path $sb 'Local\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState' -$cacheDir = Join-Path $sb 'Local\PowerShellProfile' -$ps7Dir = Join-Path $sb 'Documents\PowerShell' -$ps5Dir = Join-Path $sb 'Documents\WindowsPowerShell' - -New-Item -ItemType Directory -Path $wtLocal, $cacheDir, $ps7Dir, $ps5Dir -Force | Out-Null - -# WT: original settings + 2 backups with different content -[System.IO.File]::WriteAllText((Join-Path $wtLocal 'settings.json'), '{"modified": true}', [System.Text.UTF8Encoding]::new($false)) -$bakOld = Join-Path $wtLocal 'settings.json.20240101-100000.bak' -$bakNew = Join-Path $wtLocal 'settings.json.20240601-120000.bak' -[System.IO.File]::WriteAllText($bakOld, '{"original": "old"}', [System.Text.UTF8Encoding]::new($false)) -Start-Sleep -Milliseconds 50 -[System.IO.File]::WriteAllText($bakNew, '{"original": "newest"}', [System.Text.UTF8Encoding]::new($false)) - -# Cache files -foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json')) { - [System.IO.File]::WriteAllText((Join-Path $cacheDir $f), "# $f placeholder", [System.Text.UTF8Encoding]::new($false)) -} - -# User plugins dir: must survive a plain uninstall (user-authored, auto-loaded scripts); only -RemoveUserData removes it. -$pluginsDir = Join-Path $cacheDir 'plugins' -New-Item -ItemType Directory -Path $pluginsDir -Force | Out-Null -[System.IO.File]::WriteAllText((Join-Path $pluginsDir 'myplugin.ps1'), '# user plugin', [System.Text.UTF8Encoding]::new($false)) - -# Profile files in both dirs -foreach ($d in @($ps7Dir, $ps5Dir)) { - [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), '# profile', [System.Text.UTF8Encoding]::new($false)) - [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user overrides', [System.Text.UTF8Encoding]::new($false)) -} - -# 4. Override environment to point at sandbox -$origLocal = $env:LOCALAPPDATA -$origProfile = $PROFILE -$env:LOCALAPPDATA = Join-Path $sb 'Local' -$global:PROFILE = Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1' - +. $PspPostLoadStubs +$utf8 = [System.Text.UTF8Encoding]::new($false) $errors = @() -try { - # ===== TEST A: Core uninstall (no switches) ===== - Uninstall-Profile -Confirm:$false - - # WT settings.json should have backup content - $wtContent = [System.IO.File]::ReadAllText((Join-Path $wtLocal 'settings.json')) - if ($wtContent -ne '{"original": "newest"}') { $errors += "WT restore: expected backup content, got: $wtContent" } - - # All backups should be gone - $remainingBaks = Get-ChildItem $wtLocal -Filter '*.bak' -ErrorAction SilentlyContinue - if ($remainingBaks) { $errors += "WT backups: $($remainingBaks.Count) backup(s) still exist" } - - # Cache: omp-init, zoxide-init, theme.json, terminal-config.json should be gone - foreach ($f in @('omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json')) { - if (Test-Path (Join-Path $cacheDir $f)) { $errors += "Cache: $f still exists" } - } - - # Cache: user-settings.json should be PRESERVED (no -RemoveUserData) - if (-not (Test-Path (Join-Path $cacheDir 'user-settings.json'))) { $errors += 'Cache: user-settings.json was deleted (should be preserved)' } - - # Cache: plugins/ (user-authored scripts) should be PRESERVED (no -RemoveUserData) - if (-not (Test-Path (Join-Path $pluginsDir 'myplugin.ps1'))) { $errors += 'Cache: plugins/myplugin.ps1 was deleted (should be preserved without -RemoveUserData)' } - - # Profile files should be gone from both dirs - foreach ($d in @($ps7Dir, $ps5Dir)) { - $pf = Join-Path $d 'Microsoft.PowerShell_profile.ps1' - if (Test-Path $pf) { $errors += "Profile: $pf still exists" } - } - - # profile_user.ps1 should be PRESERVED (no -RemoveUserData) - foreach ($d in @($ps7Dir, $ps5Dir)) { - $uf = Join-Path $d 'profile_user.ps1' - if (-not (Test-Path $uf)) { $errors += "User profile: $uf was deleted (should be preserved)" } - } - - # ===== TEST B: Recreate deleted files and test -RemoveUserData ===== - [System.IO.File]::WriteAllText((Join-Path $ps7Dir 'Microsoft.PowerShell_profile.ps1'), '# profile', [System.Text.UTF8Encoding]::new($false)) - [System.IO.File]::WriteAllText((Join-Path $ps5Dir 'Microsoft.PowerShell_profile.ps1'), '# profile', [System.Text.UTF8Encoding]::new($false)) - - Uninstall-Profile -RemoveUserData -Confirm:$false - - # user-settings.json should now be gone - if (Test-Path (Join-Path $cacheDir 'user-settings.json')) { $errors += 'RemoveUserData: user-settings.json still exists' } - - # plugins/ should now be gone with -RemoveUserData - if (Test-Path $pluginsDir) { $errors += 'RemoveUserData: plugins/ still exists' } - - # profile_user.ps1 should now be gone from both dirs - foreach ($d in @($ps7Dir, $ps5Dir)) { - $uf = Join-Path $d 'profile_user.ps1' - if (Test-Path $uf) { $errors += "RemoveUserData: $uf still exists" } - } - - # Cache dir itself should be gone (empty after full removal) - if ((Test-Path $cacheDir) -and (Get-ChildItem $cacheDir -ErrorAction SilentlyContinue)) { - $leftover = (Get-ChildItem $cacheDir).Name -join ', ' - $errors += "Cache dir not empty: $leftover" - } -} -finally { - # 4. Restore real env - $env:LOCALAPPDATA = $origLocal - $global:PROFILE = $origProfile - # Cleanup sandbox - Remove-Item $sb -Recurse -Force -ErrorAction SilentlyContinue -} - -if ($errors) { - $errors | ForEach-Object { Write-Host "ASSERT: $_" -ForegroundColor Red } - exit 1 -} -Write-Host 'All sandbox assertions passed' +New-Item -ItemType Directory -Path $Sandbox.WtDir, $Sandbox.Cache, $Sandbox.Ps5Dir -Force | Out-Null +# A settings file from an older profile version: no change record, two timestamped backups. +$wtPath = Join-Path $Sandbox.WtDir 'settings.json' +[System.IO.File]::WriteAllText($wtPath, '{"modified": true}', $utf8) +[System.IO.File]::WriteAllText("$wtPath.20240101-100000.bak", '{"original": "old"}', $utf8) +(Get-Item -LiteralPath "$wtPath.20240101-100000.bak").LastWriteTime = (Get-Date).AddDays(-30) +[System.IO.File]::WriteAllText("$wtPath.20240601-120000.bak", '{"original": "newest"}', $utf8) +foreach ($f in 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json') { + [System.IO.File]::WriteAllText((Join-Path $Sandbox.Cache $f), '{}', $utf8) +} +$pluginsDir = Join-Path $Sandbox.Cache 'plugins' +New-Item -ItemType Directory -Path $pluginsDir -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $pluginsDir 'myplugin.ps1'), '# user plugin', $utf8) +foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { + [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), '# profile', $utf8) + [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user overrides', $utf8) +} + +Uninstall-Profile -Confirm:$false 6>$null +# The oldest backup is the closest record of the pre-install file; the current file is kept next to it. +$wtContent = [System.IO.File]::ReadAllText($wtPath) +if ($wtContent -ne '{"original": "old"}') { $errors += "WT restore: expected the oldest backup, got: $wtContent" } +if (Get-ChildItem -LiteralPath $Sandbox.WtDir -Filter '*.bak') { $errors += 'WT backups left after a successful restore' } +if (-not (Get-ChildItem -LiteralPath $Sandbox.WtDir -Filter 'settings.json.pre-uninstall-*.json')) { $errors += 'current WT settings not kept' } +foreach ($f in 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json') { + if (Test-Path -LiteralPath (Join-Path $Sandbox.Cache $f)) { $errors += "Cache: $f still exists" } +} +if (-not (Test-Path -LiteralPath (Join-Path $Sandbox.Cache 'user-settings.json'))) { $errors += 'user-settings.json deleted without -RemoveUserData' } +if (-not (Test-Path -LiteralPath (Join-Path $pluginsDir 'myplugin.ps1'))) { $errors += 'plugins deleted without -RemoveUserData' } +foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { + if (Test-Path -LiteralPath (Join-Path $d 'Microsoft.PowerShell_profile.ps1')) { $errors += "profile still in $d" } + if (-not (Test-Path -LiteralPath (Join-Path $d 'profile_user.ps1'))) { $errors += "profile_user.ps1 deleted in $d" } +} + +foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), '# profile', $utf8) } +Uninstall-Profile -RemoveUserData -Confirm:$false 6>$null +if (Test-Path -LiteralPath (Join-Path $Sandbox.Cache 'user-settings.json')) { $errors += 'RemoveUserData: user-settings.json still exists' } +if (Test-Path -LiteralPath $pluginsDir) { $errors += 'RemoveUserData: plugins/ still exists' } +foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { + if (Test-Path -LiteralPath (Join-Path $d 'profile_user.ps1')) { $errors += "RemoveUserData: profile_user.ps1 still in $d" } +} +if ((Test-Path -LiteralPath $Sandbox.Cache) -and (Get-ChildItem -LiteralPath $Sandbox.Cache -Force)) { $errors += "cache not empty: $((Get-ChildItem -LiteralPath $Sandbox.Cache -Force).Name -join ', ')" } +if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } +Write-Host ' OK core uninstall and -RemoveUserData' '@ - [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" } + Show-ChildOutput $r.Output + if ($r.ExitCode -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) +# 20. Uninstall-Profile sandbox: -All (font removal limited to the sandbox) # ------------------------------------------------------- -Write-Host '[20/26] Uninstall sandbox: -All' -ForegroundColor Cyan +Write-Host '[20/27] 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' + $r = Invoke-SandboxChild -Body @' +$WarningPreference = 'SilentlyContinue' . $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' - +. $PspPostLoadStubs +$utf8 = [System.Text.UTF8Encoding]::new($false) $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' +New-Item -ItemType Directory -Path $Sandbox.WtDir, $Sandbox.Cache, $Sandbox.Ps5Dir -Force | Out-Null +$wtPath = Join-Path $Sandbox.WtDir 'settings.json' +[System.IO.File]::WriteAllText($wtPath, '{"modified": true}', $utf8) +[System.IO.File]::WriteAllText("$wtPath.20240601-120000.bak", '{"original": true}', $utf8) +foreach ($f in 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json') { + [System.IO.File]::WriteAllText((Join-Path $Sandbox.Cache $f), '{}', $utf8) +} +foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { + [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), '# profile', $utf8) + [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user', $utf8) +} +$fonts = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' +New-Item -ItemType Directory -Path $fonts -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $fonts 'CaskaydiaCoveNerdFont-Regular.ttf'), 'x', $utf8) +$script:PSP.TestFontScopes = @(@{ Name = 'per-user'; Dir = $fonts; Reg = 'HKCU:\SOFTWARE\PowerShellPerfectTest\Fonts-NotPresent'; NeedsAdmin = $false }) + +# CI is set inside the sandbox, so -All skips the winget and module removals. +Uninstall-Profile -All -Confirm:$false 6>$null +if ([System.IO.File]::ReadAllText($wtPath) -ne '{"original": true}') { $errors += 'WT restore failed' } +if (Get-ChildItem -LiteralPath $Sandbox.WtDir -Filter '*.bak') { $errors += 'Backups remain' } +foreach ($f in 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'user-settings.json') { + if (Test-Path -LiteralPath (Join-Path $Sandbox.Cache $f)) { $errors += "Cache: $f still exists" } +} +foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { + foreach ($f in 'Microsoft.PowerShell_profile.ps1', 'profile_user.ps1') { + if (Test-Path -LiteralPath (Join-Path $d $f)) { $errors += "$f still in $d" } + } +} +if (Test-Path -LiteralPath (Join-Path $fonts 'CaskaydiaCoveNerdFont-Regular.ttf')) { $errors += 'sandbox Nerd Font not removed' } +if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } +Write-Host ' OK -All removed everything inside the sandbox' '@ - [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" } + Show-ChildOutput $r.Output + if ($r.ExitCode -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 +# 21. Lifecycle: install -> uninstall -> reinstall (setup.ps1 -LocalRepo, no network) # ------------------------------------------------------- -Write-Host '[21/26] Lifecycle: install -> uninstall -> reinstall' -ForegroundColor Cyan +Write-Host '[21/27] 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' - + $r = Invoke-SandboxChild -Body @' +$WarningPreference = 'SilentlyContinue' +$utf8 = [System.Text.UTF8Encoding]::new($false) $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 -} +New-Item -ItemType Directory -Path $Sandbox.WtDir -Force | Out-Null +$wtPath = Join-Path $Sandbox.WtDir 'settings.json' +$wtOriginal = '{"profiles":{"defaults":{"font":{"face":"Consolas"}},"list":[]},"schemes":[],"actions":[]}' +[System.IO.File]::WriteAllText($wtPath, $wtOriginal, $utf8) +$installed = @( + (Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1'), (Join-Path $Sandbox.Ps5Dir 'Microsoft.PowerShell_profile.ps1'), + (Join-Path $Sandbox.Cache 'theme.json'), (Join-Path $Sandbox.Cache 'terminal-config.json'), (Join-Path $Sandbox.Cache 'user-settings.json'), + (Join-Path $Sandbox.Ps7Dir 'profile_user.ps1'), (Join-Path $Sandbox.Ps5Dir 'profile_user.ps1') +) -if ($errors) { - $errors | ForEach-Object { Write-Host "ASSERT: $_" -ForegroundColor Red } - exit 1 -} -Write-Host 'Lifecycle test passed (install -> uninstall -> reinstall)' +Write-Host ' --- Phase 1: Install ---' +& (Join-Path $RepoRoot 'setup.ps1') -LocalRepo $RepoRoot -CiMode -SkipWizard *> $null +$missing = @($installed | Where-Object { -not (Test-Path -LiteralPath $_) }) +if ($missing) { $errors += "Install missing: $($missing -join ', ')" } else { Write-Host ' OK Install: all files created' } +. (Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1') +. $PspPostLoadStubs +if (-not (Get-Command 'Uninstall-Profile' -ErrorAction SilentlyContinue)) { throw 'Uninstall-Profile not available after install' } + +Write-Host ' --- Phase 2: Uninstall -All ---' +Uninstall-Profile -All -Confirm:$false 6>$null +$left = @($installed | Where-Object { Test-Path -LiteralPath $_ }) +if ($left) { $errors += "After uninstall: $($left -join ', ')" } +$restored = Get-Content -LiteralPath $wtPath -Raw | ConvertFrom-Json +if ((ConvertTo-PspCanonicalJson $restored) -ne (ConvertTo-PspCanonicalJson ($wtOriginal | ConvertFrom-Json))) { $errors += 'WT settings not restored to the pre-install values' } +if (Get-WtProfileBackups -Path $wtPath) { $errors += 'WT backups remain after uninstall' } +Write-Host ' OK Uninstall: removed and restored' + +Write-Host ' --- Phase 3: Reinstall ---' +& (Join-Path $RepoRoot 'setup.ps1') -LocalRepo $RepoRoot -CiMode -SkipWizard *> $null +$missing = @($installed | Where-Object { -not (Test-Path -LiteralPath $_) }) +if ($missing) { $errors += "Reinstall missing: $($missing -join ', ')" } else { Write-Host ' OK Reinstall: all files recreated' } +. (Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1') +. $PspPostLoadStubs +if (-not (Get-Command 'Show-Help' -ErrorAction SilentlyContinue)) { $errors += 'Reinstall: profile failed to load' } +if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } '@ - [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' } + Show-ChildOutput $r.Output + if ($r.ExitCode -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 +Write-Host '[22/27] 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) + $online = $false + try { $online = (Invoke-WebRequest -Uri 'https://github.com' -Method Head -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop).StatusCode -eq 200 } catch { $online = $false } + # The child reads the result through the environment (its param block takes only -RepoRoot). + $env:PSP_TEST_ONLINE = [string]$online + $r = Invoke-SandboxChild -Body @' $ErrorActionPreference = 'Continue' -$env:CI = 'true' +$WarningPreference = 'SilentlyContinue' . $ProfileSource +. $PspPostLoadStubs +$Online = $env:PSP_TEST_ONLINE -eq 'True' $ok = 0; $fail = 0; $skip = 0; $netFail = 0 +# Return $true when an exception chain contains a transport failure (DNS, TCP, TLS handshake, timeout), judged by type, not message text. +function Test-TransportFailure { + param([System.Exception]$Exception) + # Compared by name: System.Net.Http is not always loaded in Windows PowerShell 5.1. + $transportTypes = @('System.Net.Http.HttpRequestException', 'System.Net.WebException', 'System.Net.Sockets.SocketException', 'System.TimeoutException', 'System.Threading.Tasks.TaskCanceledException', 'System.Security.Authentication.AuthenticationException') + for ($e = $Exception; $e; $e = $e.InnerException) { + for ($t = $e.GetType(); $t; $t = $t.BaseType) { if ($transportTypes -contains $t.FullName) { return $true } } + } + return $false +} +# Run one command. -Net marks commands that need the internet: they are skipped offline, and only a transport exception counts as a network failure. function T { - param([string]$N, [scriptblock]$C, [string]$S) + param([string]$N, [scriptblock]$C, [string]$S, [switch]$Net) if ($S) { Write-Host " SKIP $N ($S)"; $script:skip++; return } + if ($Net -and -not $Online) { Write-Host " SKIP $N (offline)"; $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') { + if ($Net -and (Test-TransportFailure $_.Exception)) { Write-Host " NET $N ($msg)"; $script:netFail++ } else { Write-Host " FAIL $N ($msg)"; $script:fail++ @@ -1138,8 +778,7 @@ function T { } # --- Temp workspace --- -$ws = Join-Path $env:TEMP "psp-exec-$([System.IO.Path]::GetRandomFileName())" -New-Item -ItemType Directory $ws -Force | Out-Null +$ws = $Sandbox.Work $tf = Join-Path $ws 'sample.txt' $tf2 = Join-Path $ws 'sample2.txt' $jf = Join-Path $ws 'sample.json' @@ -1173,7 +812,7 @@ 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')) { + foreach ($p in @('Force', 'SkipHashCheck', 'ExpectedSha256', 'Ref', 'InstallMissingTools', 'WhatIf')) { if (-not $cmd.Parameters.ContainsKey($p)) { throw "Update-Profile missing parameter: $p" } } # -WhatIf must prevent Phase 1 downloads via the ShouldProcess gate. @@ -1186,27 +825,36 @@ T 'Update-Profile' { T 'Update-PowerShell' { # -WhatIf: a live run spawns a winget MSI upgrade that replaces the running pwsh. Update-PowerShell -WhatIf *> $null -} +} -Net 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' + # winget is replaced by a recorder and one tool is reported as installed, so the upgrade path runs without changing anything. + $script:wingetCalls = @() + function winget { $script:wingetCalls += ($args -join ' '); $global:LASTEXITCODE = -1978335189 } + function Get-ProfileToolExecutablePath { param($Tool) if ($Tool.Name -eq 'bat') { 'C:\psp-test\bat.exe' } } + function Get-ProfileToolVersionText { param($Tool, $ExecutablePath) '1.0.0' } + Update-Tools -Confirm:$false *> $null + if (@($script:wingetCalls | Where-Object { $_ -match '^upgrade --id sharkdp\.bat\b' }).Count -ne 1) { throw "unexpected winget calls: $($script:wingetCalls -join ' | ')" } +} +T 'Clear-ProfileCache' { + [System.IO.File]::WriteAllText((Join-Path (Get-PspCacheDir) 'omp-init.ps1'), '# cache', [System.Text.UTF8Encoding]::new($false)) + Clear-ProfileCache -Confirm:$false *> $null + if (Test-Path -LiteralPath (Join-Path (Get-PspCacheDir) 'omp-init.ps1')) { throw 'cache file not removed' } +} +T 'Clear-Cache' { + $junk = Join-Path $env:TEMP 'psp-clear-cache-probe.tmp' + [System.IO.File]::WriteAllText($junk, 'x', [System.Text.UTF8Encoding]::new($false)) + Clear-Cache -Confirm:$false *> $null + if (Test-Path -LiteralPath $junk) { throw 'sandbox temp file not removed' } +} +T 'Uninstall-Profile' $null 'tested in sandbox 19/20/21' 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) + $pre = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*' -ErrorAction SilentlyContinue) Invoke-ProfileWizard -WhatIf -Confirm:$false | Out-Null - $post = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*.ps1' -ErrorAction SilentlyContinue) + $post = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*' -ErrorAction SilentlyContinue) if ($post.Count -gt $pre.Count) { throw "Invoke-ProfileWizard -WhatIf leaked temp files" } $cmd = Get-Command Invoke-ProfileWizard - foreach ($p in @('Resume', 'NoElevate', 'ExpectedSha256', 'BundleExpectedSha256', 'SkipHashCheck', 'WhatIf')) { + foreach ($p in @('Resume', 'NoElevate', 'ExpectedSha256', 'BundleExpectedSha256', 'SkipHashCheck', 'Ref', 'WhatIf')) { if (-not $cmd.Parameters.ContainsKey($p)) { throw "Invoke-ProfileWizard missing parameter: $p" } } } @@ -1234,6 +882,8 @@ T 'la' { la $ws } T 'll' { ll $ws } T 'lt' { lt $ws } T 'cat' { cat $tf } +$batSkip = if (Get-Command bcat -ErrorAction SilentlyContinue) { $null } else { 'bat not installed (bcat is defined only with bat)' } +T 'bcat' { bcat $tf } $batSkip 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') } @@ -1242,7 +892,8 @@ 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 } +# -WhatIf: a real run moves the file into this user's Recycle Bin. +T 'trash' { $t = Join-Path $ws 'trashme.txt'; [System.IO.File]::WriteAllText($t, 'bye', [System.Text.UTF8Encoding]::new($false)); trash $t -WhatIf; if (-not (Test-Path -LiteralPath $t)) { throw 'trash -WhatIf removed the file' } } 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 } @@ -1293,69 +944,68 @@ 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 'pkill' { pkill 'psp-no-such-process' 3>$null } 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 'pubip' { pubip } -Net 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 'checkport' { checkport "dns.google" 443 } -Net +T 'portscan' { portscan "dns.google" -Ports @(53, 443) } -Net +T 'tlscert' { tlscert "google.com" } -Net +T 'ipinfo' { ipinfo "8.8.8.8" } -Net +T 'whois' { whois "example.com" } -Net +T 'nslook' { nslook "google.com" } -Net T 'env' { env "PATH" } T 'svc' { svc "idle" -Count 1 } T 'eventlog' { eventlog 1 } -T 'weather' { weather "London" } +T 'weather' { weather "London" } -Net 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) + $markerLiteral = $marker -replace "'", "''" + $scriptBody = "[System.IO.File]::WriteAllText('$markerLiteral', 'ran', [System.Text.UTF8Encoding]::new(`$false))" + $script:winutilBytes = [System.Text.Encoding]::UTF8.GetBytes($scriptBody) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { $expectedHash = [BitConverter]::ToString($sha.ComputeHash($script:winutilBytes)).Replace('-', '') } finally { $sha.Dispose() } + $savedAdmin = $script:PSP.IsAdmin 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 hashes and runs the downloaded bytes in memory; serve a local script instead of the real one. + function Invoke-WebRequest { + param([string]$Uri, [int]$TimeoutSec, [switch]$UseBasicParsing, $ErrorAction) + [PSCustomObject]@{ RawContentStream = [System.IO.MemoryStream]::new($script:winutilBytes) } } - winutil + winutil 6>$null if (Test-Path $marker) { throw 'winutil executed without explicit opt-in' } - winutil -Force -WhatIf + winutil -Force -WhatIf 6>$null if (Test-Path $marker) { throw 'winutil executed under -WhatIf' } - winutil -ExpectedSha256 $expectedHash -Confirm:$false + # Unelevated, WinUtil would relaunch itself from its latest release, so a pinned run needs an elevated shell. + $script:PSP.IsAdmin = $false + winutil -ExpectedSha256 $expectedHash -Confirm:$false -ErrorAction SilentlyContinue 6>$null + if (Test-Path $marker) { throw 'winutil executed unelevated' } + $script:PSP.IsAdmin = $true + + winutil -ExpectedSha256 ('0' * 64) -Confirm:$false -ErrorAction SilentlyContinue 6>$null + if (Test-Path $marker) { throw 'winutil executed after a hash mismatch' } + + winutil -ExpectedSha256 $expectedHash -Confirm:$false 6>$null 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 + $script:PSP.IsAdmin = $savedAdmin + Remove-Item Function:\Invoke-WebRequest -ErrorAction SilentlyContinue Remove-Item $marker -Force -ErrorAction SilentlyContinue } } @@ -1385,12 +1035,15 @@ T 'harden' { } } +# Commands that write to the clipboard run only on CI runners: locally they would overwrite the developer's clipboard. +$clipSkip = if ($env:GITHUB_ACTIONS) { $null } else { 'would overwrite your clipboard (runs on CI only)' } + # ===== 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 'genpass' { genpass 16 } $clipSkip T 'b64' { b64 "hello world" } T 'b64d' { b64d "aGVsbG8gd29ybGQ=" } T 'jwtd' { jwtd "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" } @@ -1410,7 +1063,7 @@ T 'Test-ProfileHistorySafeLine' { if (-not (Test-ProfileHistorySafeLine $safe)) { throw "safe command blocked from history: $safe" } } } -T 'uuid' { uuid } +T 'uuid' { uuid } $clipSkip T 'epoch' { epoch } T 'epoch 0' { epoch 0 } T 'urlencode' { urlencode "hello world" } @@ -1422,7 +1075,7 @@ T 'vt' { } # ===== DEVELOPER ===== -T 'killport' $null 'destructive - kills process' +T 'killport' { killport 1 -WhatIf 3>$null 6>$null } T 'Stop-ListeningPort' $null 'interactive fzf picker' T 'killports' $null 'alias to Stop-ListeningPort' T 'Find-FileLocker' { @@ -1435,11 +1088,10 @@ T 'Find-FileLocker' { } 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 'Remove-LockedItem' { $li = Join-Path $ws 'locked-probe.txt'; [System.IO.File]::WriteAllText($li, 'x', [System.Text.UTF8Encoding]::new($false)); Remove-LockedItem $li -WhatIf 6>$null; if (-not (Test-Path -LiteralPath $li)) { throw 'Remove-LockedItem -WhatIf deleted the file' } } +T 'http' { http "https://httpbin.org/get" } -Net T 'prettyjson' { prettyjson $jf } # -WhatIf stops at the ShouldProcess gate; a live call publishes $tf to a PUBLIC paste. -# ci-functional.ps1 and rawhunt.ps1 cover the real upload on purpose. T 'hb' { if (hb $tf -WhatIf) { throw 'hb published under -WhatIf' } } T 'timer' { timer { Start-Sleep -Milliseconds 10 } } T 'watch' $null 'infinite loop' @@ -1459,14 +1111,14 @@ T 'dprune' $null 'destructive' # ===== SSH & REMOTE ===== $hasSsh = [bool](Get-Command ssh -ErrorAction SilentlyContinue) -T 'Copy-SshKey' $null 'needs remote host' +T 'Copy-SshKey' $null 'needs remote host (remote script covered by tests/unit.ps1)' 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 'cpy' { cpy "psp-test-clip" } $clipSkip +T 'pst' { $r = pst; if ($r -ne "psp-test-clip") { throw "expected psp-test-clip, got: $r" } } $clipSkip T 'icb' $null 'PSReadLine handler only' T 'Invoke-Clipboard' $null 'PSReadLine handler only' @@ -1492,8 +1144,8 @@ T 'lsblk' { lsblk } T 'htop' $null 'launches TUI' T 'mtr' $null 'long traceroute+ping loop' T 'fwallow' { - $origIsAdmin = $script:isAdmin - $script:isAdmin = $true + $origIsAdmin = $script:PSP.IsAdmin + $script:PSP.IsAdmin = $true $script:fwCalls = @() try { function New-NetFirewallRule { @@ -1521,14 +1173,14 @@ T 'fwallow' { if ($script:fwCalls[0].Action -ne 'Allow') { throw "fwallow recorded wrong action: $($script:fwCalls[0].Action)" } } finally { - $script:isAdmin = $origIsAdmin + $script:PSP.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 + $origIsAdmin = $script:PSP.IsAdmin + $script:PSP.IsAdmin = $true $script:fwCalls = @() try { function New-NetFirewallRule { @@ -1556,7 +1208,7 @@ T 'fwblock' { if ($script:fwCalls[0].Action -ne 'Block') { throw "fwblock recorded wrong action: $($script:fwCalls[0].Action)" } } finally { - $script:isAdmin = $origIsAdmin + $script:PSP.IsAdmin = $origIsAdmin Remove-Item Function:\New-NetFirewallRule -ErrorAction SilentlyContinue Remove-Variable -Name fwCalls -Scope Script -ErrorAction SilentlyContinue } @@ -1567,16 +1219,16 @@ 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 'pwnd' { pwnd 'password' 3>$null } -Net +T 'certcheck' { certcheck 'example.com' } -Net T 'entropy' { entropy $tf } # ===== DEVELOPER+ ===== T 'serve' $null 'long-running HTTP server' -T 'gitignore' { Push-Location $ws; try { gitignore 'python' } finally { Pop-Location } } +T 'gitignore' { Push-Location $ws; try { gitignore 'python' } finally { Pop-Location } } -Net 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 'tldr' { tldr 'ls' } -Net 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' @@ -1596,34 +1248,24 @@ 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)' +T 'Add-TrustedDirectory' { $td = Join-Path $ws 'trusted'; New-Item -ItemType Directory -Path $td -Force | Out-Null; Add-TrustedDirectory -Path $td -Confirm:$false 6>$null; if ($script:PSP.TrustedDirs -notcontains $td) { throw 'not trusted' } } +T 'Remove-TrustedDirectory' { Remove-TrustedDirectory -Path (Join-Path $ws 'trusted') -Confirm:$false 6>$null; if ($script:PSP.TrustedDirs -contains (Join-Path $ws 'trusted')) { throw 'still trusted' } } # ===== THEME ===== -T 'Set-TerminalBackground' $null 'persists to user-settings.json (tested in sandbox)' - -# --- Cleanup --- -Remove-Item $ws -Recurse -Force -ErrorAction SilentlyContinue +T 'Set-TerminalBackground' { $img = Join-Path $ws 'bg.png'; [System.IO.File]::WriteAllBytes($img, [byte[]](0x89, 0x50, 0x4E, 0x47)); Set-TerminalBackground -Path $img -Confirm:$false 6>$null; Set-TerminalBackground -Clear -Confirm:$false 6>$null } # --- 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' }) +Write-Host $summary 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" } + Show-ChildOutput $r.Output + $fnFails = @($r.Output | Where-Object { $_ -match '^\s+FAIL' }) + if ($r.ExitCode -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 @@ -1633,326 +1275,174 @@ Write-Host '--- Setup & install verification ---' -ForegroundColor Magenta Write-Host '' # ------------------------------------------------------- -# 23. setup.ps1 functions sandbox (AST extract + execute) +# 23. setup.ps1 functions (hermetic: nothing is installed) # ------------------------------------------------------- -Write-Host '[23/26] setup.ps1 functions sandbox' -ForegroundColor Cyan +Write-Host '[23/27] 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 } - + $r = Invoke-SandboxChild -Body @' +$WarningPreference = 'SilentlyContinue' +. $ProfileSource +. $PspPostLoadStubs +$setupFile = Join-Path $RepoRoot 'setup.ps1' +foreach ($definition in (Get-PspFunctionSource -Path $setupFile -Name @('Install-WingetPackage', 'Install-NerdFonts', 'Invoke-SetupNative', 'Test-InternetConnection', 'Get-OmpThemeUrl', 'Remove-SafeTempDirectory'))) { + . ([scriptblock]::Create($definition)) +} +foreach ($variable in '$EditorCandidates', '$script:CuratedFonts', '$script:CuratedSchemes', '$script:NerdFontVersion', '$script:OmpThemesRef') { + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $setupFile -Variable $variable))) +} +$script:IsElevated = $false $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 +T 'Install-WingetPackage -WhatIf installs nothing' { + # The sandbox puts a recording winget.cmd first on PATH, so even a broken -WhatIf cannot reach the real winget. + $r = Install-WingetPackage -Name 'PowerShell' -Id 'Microsoft.PowerShell' -WhatIf 6>$null if ($r -ne $true) { throw "expected true, got $r" } + $calls = @(Get-PspSandboxWingetCalls -Sandbox $Sandbox) + if ($calls.Count -gt 0) { throw "winget ran under -WhatIf: $($calls -join ' | ')" } } - -# --- Invoke-DownloadWithRetry (real download) --- -T 'Invoke-DownloadWithRetry' { - $tmp = Join-Path $env:TEMP "psp-dltest-$([System.IO.Path]::GetRandomFileName()).json" - try { - Invoke-DownloadWithRetry -Uri 'https://raw.githubusercontent.com/26zl/PowerShellPerfect/main/theme.json' -OutFile $tmp - if (-not (Test-Path $tmp) -or (Get-Item $tmp).Length -eq 0) { throw 'download empty' } - $null = Get-Content $tmp -Raw | ConvertFrom-Json - } - finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } -} - -# --- Merge-JsonObject (setup.ps1 copy) --- -T 'Merge-JsonObject (setup copy)' { - $b = [PSCustomObject]@{ a = 1; n = [PSCustomObject]@{ x = 10; y = 20 } } - Merge-JsonObject $b ([PSCustomObject]@{ a = 99; n = [PSCustomObject]@{ y = 30; z = 40 } }) - if ($b.a -ne 99 -or $b.n.x -ne 10 -or $b.n.y -ne 30 -or $b.n.z -ne 40) { throw 'merge mismatch' } -} - -# --- Wizard -Resume round-trip --- -T 'Save-WizardChoices persists resumed terminal/feature choices' { - $choices = @{ - Terminal = [ordered]@{ opacity = 90; fontSize = 12; cursorShape = 'bar' } - Features = [ordered]@{ psfzf = $false; predictions = $true } - } - # Simulate state serialization and restoration. - $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 } +T 'Install-WingetPackage reaches only the sandbox winget' { + $r = Install-WingetPackage -Name 'PowerShell' -Id 'Microsoft.PowerShell' 6>$null + $calls = @(Get-PspSandboxWingetCalls -Sandbox $Sandbox) + if ($r -ne $false -or $calls.Count -ne 1 -or $calls[0] -notmatch '^install -e --id Microsoft\.PowerShell\b') { throw "unexpected: result $r, calls $($calls -join ' | ')" } } - -# --- Install-WingetPackage (already-installed path) --- -T 'Install-WingetPackage' { - $r = Install-WingetPackage -Name 'PowerShell' -Id 'Microsoft.PowerShell' +T 'Install-NerdFonts -WhatIf downloads nothing' { + $r = Install-NerdFonts -WhatIf 6>$null if ($r -ne $true) { throw "expected true, got $r" } + if (Get-ChildItem -LiteralPath $env:TEMP -Filter 'psp-font-*' -ErrorAction SilentlyContinue) { throw 'font download started under -WhatIf' } } - -# --- Install-NerdFonts (font detection) --- -T 'Install-NerdFonts (detection)' { - $r = Install-NerdFonts - if ($r -ne $true) { throw "expected true, got $r" } +T 'Install-NerdFonts rejects a bad asset name' { + if (Install-NerdFonts -FontName '../x' 6>$null) { throw 'bad asset accepted' } } - -# --- 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' } +T 'Curated fonts are pinned' { + foreach ($f in $script:CuratedFonts) { if ($f.Sha256 -notmatch '^[A-F0-9]{64}$') { throw "$($f.Name) has no pinned hash" } } + if ($script:NerdFontVersion -notmatch '^\d+\.\d+\.\d+$') { throw 'Nerd Fonts version not pinned' } +} +T 'Theme URLs use the pinned Oh My Posh release' { + if ((Get-OmpThemeUrl -Name 'pure') -notmatch "/oh-my-posh/$([regex]::Escape($script:OmpThemesRef))/themes/pure\.omp\.json$") { throw 'theme URL not pinned' } } -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' }) +Write-Host " setup.ps1 functions: $ok ok, $fail fail" 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" } + Show-ChildOutput $r.Output + if ($r.ExitCode -ne 0) { throw 'setup.ps1 function checks 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) +# 24. setprofile.ps1 sandbox (copy to both PS dirs, -WhatIf writes nothing) # ------------------------------------------------------- -Write-Host '[24/26] setprofile.ps1 sandbox' -ForegroundColor Cyan +Write-Host '[24/27] 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' + $r = Invoke-SandboxChild -Body @' +$errors = @() +$srcDir = Join-Path $Sandbox.Work 'src' 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' +Remove-Item -LiteralPath $Sandbox.Ps7Dir -Recurse -Force +& (Join-Path $srcDir 'setprofile.ps1') -WhatIf *> $null +if (Test-Path -LiteralPath (Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1')) { $errors += '-WhatIf wrote a profile' } +# The user's own PowerShell 7 profile from before the first install; Windows PowerShell has none. +New-Item -ItemType Directory -Path $Sandbox.Ps7Dir -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1'), '# my own profile', [System.Text.UTF8Encoding]::new($false)) +& (Join-Path $srcDir 'setprofile.ps1') *> $null +if ($LASTEXITCODE -ne 0) { $errors += "setprofile.ps1 exit code $LASTEXITCODE" } +foreach ($dir in @($Sandbox.Ps7Dir, $Sandbox.Ps5Dir)) { + $pf = Join-Path $dir 'Microsoft.PowerShell_profile.ps1' + if (-not (Test-Path $pf)) { $errors += "Not created: $pf" } + elseif ((Get-Content (Join-Path $srcDir 'Microsoft.PowerShell_profile.ps1') -Raw) -ne (Get-Content $pf -Raw)) { $errors += "Content mismatch: $pf" } +} +# A second run backs up the installed profile, which must never become the "original". +& (Join-Path $srcDir 'setprofile.ps1') *> $null +$original = Join-Path $Sandbox.Ps7Dir 'profile.original.ps1' +if (-not (Test-Path $original) -or (Get-Content $original -Raw).Trim() -ne '# my own profile') { $errors += 'profile.original.ps1 is not the pre-install profile' } +if (Test-Path (Join-Path $Sandbox.Ps5Dir 'profile.original.ps1')) { $errors += 'a copy of this profile was saved as profile.original.ps1' } +if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } +Write-Host ' OK setprofile.ps1 copies, backs up and honors -WhatIf' '@ - [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" } + Show-ChildOutput $r.Output + if ($r.ExitCode -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) +# 25. Install verification (read-only look at this machine's real install; the profile is not executed) # ------------------------------------------------------- -Write-Host '[25/26] Install verification' -ForegroundColor Cyan +Write-Host '[25/27] 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++ + $errors = @() + $warnings = @() + # Read $script:ProfileTools and the JSON helpers from the source instead of loading the profile. + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $profilePath -Variable '$script:ProfileTools'))) + foreach ($definition in (Get-PspFunctionSource -Path $profilePath -Name @('ConvertFrom-JsonC', 'Get-Utf8FileText', 'Read-JsonFile'))) { . ([scriptblock]::Create($definition)) } + $toolsFound = 0; $toolsMissing = 0 + foreach ($tool in $script:ProfileTools) { + if (Get-Command $tool.Cmd -CommandType Application -ErrorAction SilentlyContinue) { Write-Host " OK $($tool.Name)" -ForegroundColor Green; $toolsFound++ } + else { $warnings += "$($tool.Name): not installed"; $toolsMissing++ } + } + $cachePath = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' + if (-not (Test-Path -LiteralPath $cachePath)) { throw [System.Management.Automation.ItemNotFoundException]::new("no install on this machine ($cachePath is missing); run setup.ps1 to check one") } + else { + foreach ($cf in @('theme.json', 'terminal-config.json')) { + $cfPath = Join-Path $cachePath $cf + if (-not (Test-Path -LiteralPath $cfPath)) { $errors += "$cf not cached" } + else { try { $null = Read-JsonFile $cfPath } catch { $errors += "$cf corrupt: $_" } } } - catch { $warnings += "$($tool.Name): found but --version failed"; $toolsMissing++ } + if (-not (Test-Path -LiteralPath (Join-Path $cachePath 'user-settings.json'))) { $errors += 'user-settings.json not found' } + if (-not (Get-ChildItem -LiteralPath $cachePath -Filter '*.omp.json' -ErrorAction SilentlyContinue)) { $warnings += 'No OMP theme in cache' } } - 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: $_" } + if (-not (Test-Path -LiteralPath (Join-Path (Split-Path $PROFILE) 'profile_user.ps1'))) { $errors += 'profile_user.ps1 not found' } + if (Get-Module -ListAvailable -Name PSFzf) { Write-Host ' OK PSFzf module available' -ForegroundColor Green } else { $warnings += 'PSFzf not installed' } + $wtPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + if (Test-Path -LiteralPath $wtPath) { + try { + $wt = Read-JsonFile $wtPath + if ($wt.profiles.defaults.font.face) { Write-Host " OK WT font: $($wt.profiles.defaults.font.face)" -ForegroundColor Green } else { $warnings += 'WT missing font.face in defaults' } } + catch { $warnings += "WT parse failed: $_" } } - $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' } + foreach ($w in $warnings) { Write-Host " WARN $w" -ForegroundColor Yellow } + if ($errors) { + foreach ($e in $errors) { Write-Host " FAIL $e" -ForegroundColor Red } + throw 'Required verification checks failed (see FAIL above)' } - 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)" + Write-Result 'Install verification' 'PASS' } +catch [System.Management.Automation.ItemNotFoundException] { Write-Result 'Install verification' 'SKIP' $_.Exception.Message } +catch { Write-Result 'Install verification' 'FAIL' $_.Exception.Message } -# --- 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 +# ------------------------------------------------------- +# 26. Static checks for the hermetic-test rules +# ------------------------------------------------------- +Write-Host '[26/27] Test hygiene' -ForegroundColor Cyan +try { + $problems = @() + foreach ($file in Get-ChildItem -LiteralPath $PSScriptRoot -Filter *.ps1 -File) { + $text = Get-Content -LiteralPath $file.FullName -Raw + # Uninstall-Profile -All includes -RemoveFonts; only sandboxes that set TestFontScopes may call it. + if ($text -match 'Uninstall-Profile -All -Confirm' -and $text -notmatch 'TestFontScopes') { $problems += "$($file.Name) runs Uninstall-Profile -All without a sandboxed font scope" } } - if ($LASTEXITCODE -ne 0) { throw 'Required verification checks failed (see FAIL above)' } - Write-Result 'Install verification' 'PASS' + if ($problems) { $problems | ForEach-Object { Write-Host " $_" -ForegroundColor Red }; Write-Result 'Test hygiene' 'FAIL' "$($problems.Count) problem(s)" } + else { Write-Result 'Test hygiene' 'PASS' } } -catch { Write-Result 'Install verification' 'FAIL' $_.Exception.Message } -finally { Remove-Item $sandboxScript -Force -ErrorAction SilentlyContinue } +catch { Write-Result 'Test hygiene' 'FAIL' $_.Exception.Message } # ------------------------------------------------------- -# 26. Command coverage audit (test.ps1 vs profile exports) +# 27. Command coverage audit (test.ps1 vs profile exports) # ------------------------------------------------------- -Write-Host '[26/26] Command coverage audit' -ForegroundColor Cyan +Write-Host '[27/27] Command coverage audit' -ForegroundColor Cyan try { # Exported function names from profile source (AST) $tokens = $null; $parseErrors = $null @@ -1966,7 +1456,7 @@ try { $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 + # Internal helpers are not direct user commands: everything named *-Psp* or *-Wt* plus the list below. $internalOnly = @( 'Get-ExternalCommandPath' 'Update-SessionPathFromRegistry' @@ -2001,14 +1491,23 @@ try { 'Write-Utf8FileAtomic' 'Set-PspFeatureOverride' 'Get-LatestMainCommitSha' + 'ConvertFrom-JsonC' + 'Read-JsonFile' + 'Get-CombinedSha256' + 'Resolve-PspCommitSha' + 'Update-WindowsTerminalSettingsFile' + 'Undo-WindowsTerminalSettingsFile' + 'Copy-PspObject' + 'Merge-PspUserSettings' + 'ConvertTo-PspSortedNode' ) - $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ } + $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ -and $_ -notmatch '-(Psp|Wt)[A-Z]' } # 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 ($line -match '^\$expected = @\($') { $inExistBlock = $true; continue } if ($inExistBlock -and $line -match "^\s*\)") { break } if ($inExistBlock) { foreach ($m in [regex]::Matches($line, "'([^']+)'")) { @@ -2047,17 +1546,13 @@ try { '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) + 'Uninstall-Profile' # tested in dedicated sandbox (steps 19/20/21) and tests/unit.ps1 '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 @@ -2067,13 +1562,12 @@ try { '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 + 'Copy-SshKey' # needs remote host (remote script tested in tests/unit.ps1) 'ssh-copy-key' # needs remote host (alias) 'keygen' # writes keys to ~/.ssh 'rdp' # opens RDP UI @@ -2101,9 +1595,6 @@ try { '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 $_ }) @@ -2123,11 +1614,14 @@ try { throw "$($unexpectedSkips.Count) command(s) skipped without justification" } + # Registration is not execution: require a minimum share of the covered commands to actually run. + $minimumExecPct = 70 $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" + $detail = "functions=$($commandFns.Count), aliases=$($aliasNames.Count), executed=$($execActual.Count), skip-only=$($skipOnly.Count)/$($allowedSkipOnly.Count) allowed, exec%=$execPct (minimum $minimumExecPct)" if ($skipOnly.Count -gt 0) { Write-Host " Allowed skip-only ($($skipOnly.Count)): $($skipOnly -join ', ')" -ForegroundColor DarkGray } + if ($execPct -lt $minimumExecPct) { throw "Only $execPct% of covered commands are executed (minimum $minimumExecPct%). $detail" } Write-Result 'Command coverage audit' 'PASS' $detail } catch { Write-Result 'Command coverage audit' 'FAIL' $_.Exception.Message } diff --git a/tests/unit.ps1 b/tests/unit.ps1 new file mode 100644 index 0000000..c380547 --- /dev/null +++ b/tests/unit.ps1 @@ -0,0 +1,1081 @@ +# Hermetic regression tests for the profile and setup.ps1. Every case runs in a disposable sandbox (LOCALAPPDATA, TEMP and +# $PROFILE redirected), downloads are stubbed, and commands that would change the machine are blocked. No network access. +# pwsh -NoProfile -File tests/unit.ps1 +# powershell -NoProfile -File tests/unit.ps1 +param( + # CI runners are elevated disposable VMs; elsewhere run the suite from a normal shell. + [switch]$AllowElevated, + # Run only the cases whose name matches this pattern. + [string]$Filter = '.' +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'lib\Sandbox.ps1') +Assert-PspTestSafety -AllowElevated:$AllowElevated -ScriptName 'tests/unit.ps1' + +$repoRoot = Split-Path -Parent $PSScriptRoot +$profilePath = Join-Path $repoRoot 'Microsoft.PowerShell_profile.ps1' +$setupPath = Join-Path $repoRoot 'setup.ps1' +$utf8 = [System.Text.UTF8Encoding]::new($false) +$script:Passed = 0 +$script:Failed = 0 +$script:Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + +function Assert-True { + param([bool]$Condition, [Parameter(Mandatory)][string]$Message) + if (-not $Condition) { throw $Message } +} + +# Run one case in a fresh sandbox; the profile functions loaded below pick up the redirected locations at call time. +function Test-Case { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][scriptblock]$Body) + if ($Name -notmatch $Filter) { return } + $script:Sandbox = New-PspSandbox -Name 'unit' + Enter-PspSandbox -Sandbox $script:Sandbox + $savedPwd = Get-Location + try { + $null = & $Body + Write-Host " PASS $Name" -ForegroundColor Green + $script:Passed++ + } + catch { + Write-Host " FAIL $Name" -ForegroundColor Red + Write-Host (" {0} (line {1})" -f $_.Exception.Message, $_.InvocationInfo.ScriptLineNumber) -ForegroundColor Yellow + $script:Failed++ + } + finally { + Set-Location $savedPwd + foreach ($stub in 'Invoke-RestMethod', 'Invoke-WebRequest') { Remove-Item -Path "function:global:$stub" -ErrorAction SilentlyContinue } + Exit-PspSandbox -Sandbox $script:Sandbox + } +} + +# Serve repository files for raw.githubusercontent.com URLs (in their committed LF form), a fixed commit for the GitHub API, +# and $global:PspStubSums for release SHA256SUMS.txt requests; everything else fails as if offline. +function Install-DownloadStub { + $global:PspStubRepo = $repoRoot + Set-Item -Path 'function:global:Invoke-RestMethod' -Value { + param([string]$Uri, [string]$OutFile, $Headers, [int]$TimeoutSec, [switch]$UseBasicParsing, $ErrorAction, $Method) + if ($Uri -match '^https://api\.github\.com/repos/26zl/PowerShellPerfect/commits/') { return ('a' * 40) } + if ($Uri -match '^https://raw\.githubusercontent\.com/26zl/PowerShellPerfect/[^/]+/(.+)$') { + $text = [System.IO.File]::ReadAllText((Join-Path $global:PspStubRepo $Matches[1])) -replace "`r`n", "`n" + [System.IO.File]::WriteAllText($OutFile, $text, [System.Text.UTF8Encoding]::new($false)) + return + } + if ($Uri -match '/releases/download/v9\.9\.9/SHA256SUMS\.txt$' -and $global:PspStubSums) { + [System.IO.File]::WriteAllText($OutFile, $global:PspStubSums, [System.Text.UTF8Encoding]::new($false)) + return + } + if ($global:PspStubTheme -and $Uri -like 'https://raw.githubusercontent.com/JanDeDobbeleer/*') { + [System.IO.File]::WriteAllText($OutFile, $global:PspStubTheme, [System.Text.UTF8Encoding]::new($false)) + return + } + throw "offline (test stub): $Uri" + } + Set-Item -Path 'function:global:Invoke-WebRequest' -Value { [PSCustomObject]@{ StatusCode = 200 } } +} + +# Return the SHA-256 of a repository file as served by raw.githubusercontent.com (LF line endings). +function Get-CommittedSha256 { + param([Parameter(Mandatory)][string]$RelativePath) + $text = [System.IO.File]::ReadAllText((Join-Path $repoRoot $RelativePath)) -replace "`r`n", "`n" + $sha = [System.Security.Cryptography.SHA256]::Create() + try { [BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($text))).Replace('-', '') } + finally { $sha.Dispose() } +} + +# Write a Windows Terminal settings.json into the sandbox and return its path. +function New-WtSettings { + param([string]$Json = '{ "profiles": { "defaults": { "opacity": 95 }, "list": [] }, "schemes": [], "actions": [] }', [string]$Variant = 'Microsoft.WindowsTerminal_8wekyb3d8bbwe') + $dir = Join-Path $env:LOCALAPPDATA "Packages\$Variant\LocalState" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $path = Join-Path $dir 'settings.json' + [System.IO.File]::WriteAllText($path, $Json, [System.Text.UTF8Encoding]::new($false)) + $path +} + +Write-Host '' +Write-Host ("== tests/unit.ps1 on PowerShell {0} ==" -f $PSVersionTable.PSVersion) -ForegroundColor Cyan + +# Load the profile once, inside a sandbox, then replace the functions that reach outside it. +$loadSandbox = New-PspSandbox -Name 'load' +Enter-PspSandbox -Sandbox $loadSandbox +$WarningPreference = 'SilentlyContinue' +. $profilePath +. $PspPostLoadStubs +$WarningPreference = 'Continue' +Exit-PspSandbox -Sandbox $loadSandbox + +# Setup helpers: setup.ps1's own functions and curated data (shared helpers already came from the profile). +foreach ($definition in (Get-PspFunctionSource -Path $setupPath -Name @( + 'Save-WizardChoices', 'Resolve-WizardState', 'Set-PreferredEditorInProfiles', 'Install-OhMyPoshTheme', 'Get-OmpThemeUrl', 'Remove-SafeTempDirectory', + 'Start-InstallWizard', 'Select-WizardItem', 'Read-WizardYesNo', 'Select-PreferredEditor', 'Get-OmpThemeList', 'Test-TelemetryOptOutSet'))) { + . ([scriptblock]::Create($definition)) +} +$script:IsElevated = $false +foreach ($variable in '$script:CuratedSchemes', '$script:CuratedFonts', '$EditorCandidates', '$script:OmpThemesRef', '$script:PinnedOmpThemes') { + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $setupPath -Variable $variable))) +} + +Write-Host '-- JSON handling --' -ForegroundColor Magenta + +Test-Case 'ConvertFrom-JsonC removes comments and trailing commas but never touches strings (F-18)' { + $q = [char]34 + $text = "{`n // line comment`n ${q}url${q}: ${q}https://example.com/a//b${q}, /* block */`n ${q}quote${q}: ${q}say \${q}hi\${q} // not a comment${q},`n ${q}list${q}: [1, 2, 3,],`n ${q}nested${q}: { ${q}k${q}: ${q}v${q}, },`n}" + $o = ConvertFrom-JsonC -Text $text + Assert-True ($o.url -eq 'https://example.com/a//b') 'a // inside a string was treated as a comment' + Assert-True ($o.quote -eq 'say "hi" // not a comment') 'an escaped quote ended the string early' + Assert-True (@($o.list).Count -eq 3) 'trailing comma in an array' + Assert-True ($o.nested.k -eq 'v') 'trailing comma in an object' +} + +Test-Case 'Read-JsonFile reads UTF-8 with and without BOM on both editions (F-19)' { + $path = Join-Path $script:Sandbox.Work 'bom.json' + $value = 'caf' + [char]0xE9 + [System.IO.File]::WriteAllText($path, ('{ "name": "' + $value + '" }'), [System.Text.UTF8Encoding]::new($true)) + Assert-True ((Read-JsonFile $path).name -eq $value) 'BOM file decoded wrongly' + [System.IO.File]::WriteAllText($path, ('{ "name": "' + $value + '" }'), $utf8) + Assert-True ((Read-JsonFile $path).name -eq $value) 'BOM-less UTF-8 decoded wrongly' +} + +Test-Case 'Merge-PspUserSettings appends user keybindings to the shipped ones as a flat list' { + $term = Read-JsonFile (Join-Path $repoRoot 'terminal-config.json') + $theme = Read-JsonFile (Join-Path $repoRoot 'theme.json') + $shipped = @($term.keybindings).Count + $user = ConvertFrom-JsonC -Text '{ "keybindings": [ { "keys": "ctrl+shift+t", "command": { "action": "newTab" } } ], "theme": { "name": "x", "url": "https://raw.githubusercontent.com/a/b/c.omp.json" } }' + $result = Merge-PspUserSettings -UserSettings $user -ThemeConfig $theme -TerminalConfig $term + $keys = @($term.keybindings | ForEach-Object { Get-PspProp $_ 'keys' }) + Assert-True ($result.Keybindings -and @($term.keybindings).Count -eq $shipped + 1) 'keybindings not merged into one flat list' + Assert-True ($keys -contains 'ctrl+a' -and $keys -contains 'ctrl+shift+t') "unexpected keys: $($keys -join ', ')" + Assert-True (-not (Test-PspProp $theme.theme 'sha256')) 'pinned hash kept for a user-chosen theme URL' +} + +Write-Host '-- Windows Terminal changes (F-01, F-05) --' -ForegroundColor Magenta + +Test-Case 'Update/Undo keep later user edits and revert only what the profile changed (F-01)' { + $theme = Read-JsonFile (Join-Path $repoRoot 'theme.json') + $term = Read-JsonFile (Join-Path $repoRoot 'terminal-config.json') + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $original = @' +{ + // user comment + "theme": "dark", + "profiles": { + "defaults": { "opacity": 90, "font": { "face": "Consolas", "weight": "bold" } }, + "list": [ { "guid": "{574e775e-4f2a-5b96-ac1e-a2962a402336}", "name": "PowerShell", "source": "Windows.Terminal.PowerShellCore" } ] + }, + "schemes": [ { "name": "Campbell", "background": "#0C0C0C" } ], + "actions": [ { "command": "unfocusPane", "id": "User.ctrlaOld" } ], + "keybindings": [ { "id": "User.ctrlaOld", "keys": "ctrl+a" } ], +} +'@ + $path = New-WtSettings -Json $original + $origParsed = ConvertFrom-JsonC -Text $original + foreach ($run in 1, 2) { + Assert-True ((Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Defaults $term.defaults -WindowsTerminal $theme.windowsTerminal -Keybindings $term.keybindings -NoLogo -Confirm:$false) -eq $true) "apply run $run" + } + $applied = Read-JsonFile $path + Assert-True ($applied.profiles.defaults.colorScheme -eq $theme.windowsTerminal.colorScheme) 'colorScheme not applied' + Assert-True ($applied.profiles.defaults.font.weight -eq 'bold') 'unmanaged font key lost' + Assert-True (@($applied.schemes | Where-Object name -eq $theme.windowsTerminal.scheme.name).Count -eq 1) 'scheme not upserted exactly once' + Assert-True (Test-Path -LiteralPath "$path.psp-original") 'no durable pre-install copy' + # The user changes something after install. + $applied.profiles.defaults.opacity = 42 + $applied.profiles.list = @($applied.profiles.list) + ([PSCustomObject]@{ name = 'User added'; commandline = 'wsl.exe' }) + [System.IO.File]::WriteAllText($path, ($applied | ConvertTo-Json -Depth 50), $utf8) + $result = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Confirm:$false + $after = Read-JsonFile $path + Assert-True ($result.Reverted -gt 0 -and $result.Kept -ge 1) "unexpected revert counts $($result.Reverted)/$($result.Kept)" + Assert-True ($after.profiles.defaults.opacity -eq 42) 'user edit made after install was lost' + Assert-True (@($after.profiles.list | Where-Object name -eq 'User added').Count -eq 1) 'user-added profile was lost' + Assert-True (-not (Test-PspProp $after.profiles.defaults 'colorScheme')) 'colorScheme not removed' + Assert-True ((ConvertTo-PspCanonicalJson $after.profiles.defaults.font) -eq (ConvertTo-PspCanonicalJson $origParsed.profiles.defaults.font)) 'font not restored' + Assert-True ((ConvertTo-PspCanonicalJson @($after.keybindings)) -eq (ConvertTo-PspCanonicalJson @($origParsed.keybindings))) 'keybindings not restored' + Assert-True ($after.theme -eq 'dark') 'theme not restored' + Assert-True (-not ([string]@($after.profiles.list)[0].commandline -match '-NoLogo')) '-NoLogo not removed' +} + +Test-Case 'Undo leaves no empty arrays the profile created in a file that had none' { + $theme = Read-JsonFile (Join-Path $repoRoot 'theme.json') + $term = Read-JsonFile (Join-Path $repoRoot 'terminal-config.json') + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $original = '{ "profiles": { "defaults": { "opacity": 80 } } }' + $path = New-WtSettings -Json $original + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Defaults $term.defaults -WindowsTerminal $theme.windowsTerminal -Keybindings $term.keybindings -Confirm:$false + $null = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Confirm:$false + $after = Read-JsonFile $path + Assert-True ((ConvertTo-PspCanonicalJson $after) -eq (ConvertTo-PspCanonicalJson (ConvertFrom-JsonC -Text $original))) "not restored exactly: $(ConvertTo-PspCanonicalJson $after)" +} + +Test-Case 'A single-element array value is recorded as an array and reverted' { + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $path = New-WtSettings -Json '{ "profiles": { "defaults": {}, "list": [] } }' + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Defaults ([PSCustomObject]@{ bellStyle = [object[]]@('window') }) -Confirm:$false + $null = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Confirm:$false + Assert-True (-not (Test-PspProp (Read-JsonFile $path).profiles.defaults 'bellStyle')) 'a ["window"] value was recorded as "window" and never reverted' +} + +Test-Case 'Values an earlier profile version wrote are reverted to the pre-install copy' { + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + # An earlier version wrote opacity and font without a change record; the pre-install file is kept as .psp-original. + $path = New-WtSettings -Json '{ "profiles": { "defaults": { "opacity": 75, "font": { "face": "CaskaydiaCove NF" } }, "list": [] } }' + [System.IO.File]::WriteAllText("$path.psp-original", '{ "profiles": { "defaults": { "opacity": 95 }, "list": [] } }', $utf8) + $defaults = [PSCustomObject]@{ opacity = 75; font = [PSCustomObject]@{ face = 'CaskaydiaCove NF' } } + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Defaults $defaults -Confirm:$false + $null = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Confirm:$false + $after = (Read-JsonFile $path).profiles.defaults + Assert-True ($after.opacity -eq 95 -and -not (Test-PspProp $after 'font')) "pre-install values not restored: $(ConvertTo-PspCanonicalJson $after)" +} + +Test-Case '-NoLogo is recorded correctly for a bare-array profiles list' { + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $path = New-WtSettings -Json '{ "profiles": [ { "guid": "{a1}", "name": "PowerShell", "commandline": "pwsh.exe" } ] }' + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -NoLogo -Confirm:$false + Assert-True (@((Read-JsonFile $path).profiles.list)[0].commandline -eq 'pwsh.exe -NoLogo') '-NoLogo not added' + $null = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Confirm:$false + Assert-True (@((Read-JsonFile $path).profiles.list)[0].commandline -eq 'pwsh.exe') 'command line not restored (dropped instead)' +} + +Test-Case 'A -NoLogo an earlier profile version added is removed on uninstall' { + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $path = New-WtSettings -Json '{ "profiles": { "list": [ { "guid": "{b2}", "name": "PowerShell", "commandline": "pwsh.exe -NoLogo" } ] } }' + [System.IO.File]::WriteAllText("$path.psp-original", '{ "profiles": { "list": [ { "guid": "{b2}", "name": "PowerShell", "commandline": "pwsh.exe" } ] } }', $utf8) + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -NoLogo -Confirm:$false + $null = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Confirm:$false + Assert-True (@((Read-JsonFile $path).profiles.list)[0].commandline -eq 'pwsh.exe') 'the earlier -NoLogo was left behind' +} + +Test-Case 'Undo keeps a profile action that the user bound to more keys' { + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $path = New-WtSettings -Json '{ "profiles": { "defaults": {}, "list": [] }, "actions": [], "keybindings": [] }' + $kb = @([PSCustomObject]@{ keys = 'ctrl+shift+f'; command = [PSCustomObject]@{ action = 'find' } }) + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Keybindings $kb -Confirm:$false + $wt = Read-JsonFile $path + $id = [string]@($wt.actions)[0].id + $wt.keybindings = @($wt.keybindings) + ([PSCustomObject]@{ id = $id; keys = 'alt+f' }) + [System.IO.File]::WriteAllText($path, ($wt | ConvertTo-Json -Depth 20), $utf8) + $null = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Confirm:$false + $after = Read-JsonFile $path + Assert-True (@($after.actions | Where-Object { $_.id -eq $id }).Count -eq 1) 'the action was removed while alt+f still points at it' +} + +Test-Case 'Undo-WindowsTerminalSettingsFile is a high-impact command' { + $binding = (Get-Command Undo-WindowsTerminalSettingsFile).ScriptBlock.Attributes | Where-Object { $_ -is [System.Management.Automation.CmdletBindingAttribute] } + Assert-True ($binding.ConfirmImpact -eq 'High') "ConfirmImpact is $($binding.ConfirmImpact)" +} + +Test-Case 'Uninstall-Profile reverts every Windows Terminal variant (F-01)' { + $stable = New-WtSettings + $preview = New-WtSettings -Variant 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe' + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $defaults = [PSCustomObject]@{ opacity = 75; font = [PSCustomObject]@{ face = 'CaskaydiaCove NF' } } + foreach ($p in $stable, $preview) { $null = Update-WindowsTerminalSettingsFile -Path $p -ManifestPath $manifest -Defaults $defaults -Confirm:$false } + Uninstall-Profile -Confirm:$false 6>$null | Out-Null + foreach ($p in $stable, $preview) { + $wt = Read-JsonFile $p + Assert-True ($wt.profiles.defaults.opacity -eq 95) "opacity not restored in $p" + Assert-True (-not (Test-PspProp $wt.profiles.defaults 'font')) "font left behind in $p" + # The pre-install copy stays until -RemoveUserData; the rotating backups go. + $left = @(Get-WtProfileBackups -Path $p) + Assert-True ($left.Count -eq 1 -and $left[0].Name -like '*.psp-original') "unexpected backups next to ${p}: $(@($left | ForEach-Object { $_.Name }) -join ', ')" + } +} + +Test-Case 'Uninstall-Profile -RemoveUserData also removes the pre-install copy' { + $path = New-WtSettings + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Defaults ([PSCustomObject]@{ opacity = 75 }) -Confirm:$false + Uninstall-Profile -RemoveUserData -Confirm:$false 6>$null | Out-Null + Assert-True ((Read-JsonFile $path).profiles.defaults.opacity -eq 95) 'not reverted' + Assert-True (@(Get-WtProfileBackups -Path $path).Count -eq 0) 'backups left with -RemoveUserData' +} + +Test-Case 'Uninstall-Profile -HardResetWindowsTerminal drops the change record of the deleted file' { + $path = New-WtSettings + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Defaults ([PSCustomObject]@{ opacity = 75 }) -Confirm:$false + Uninstall-Profile -HardResetWindowsTerminal -Confirm:$false 6>$null | Out-Null + Assert-True (-not (Test-Path -LiteralPath $path)) 'settings.json not deleted' + Assert-True (-not (Get-PspProp (Read-WtChangeManifest -ManifestPath $manifest).files $path.ToLowerInvariant())) 'the record of the deleted file was kept' +} + +Test-Case 'An unreadable user-settings.json leaves settings.json alone in Uninstall-Profile' { + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path (Get-PspCacheDir) 'user-settings.json'), '{ "windowsTerminal": ', $utf8) + $path = New-WtSettings -Json '{"profiles":{"defaults":{"opacity":75}}}' + [System.IO.File]::WriteAllText("$path.20240101-000000.bak", '{"stale":true}', $utf8) + $before = [System.IO.File]::ReadAllText($path) + $out = Uninstall-Profile -Confirm:$false *>&1 | Out-String + Assert-True ([System.IO.File]::ReadAllText($path) -eq $before) 'settings.json was changed' + Assert-True ($out -match 'could not be read') 'no warning about the unreadable file' +} + +Test-Case 'Uninstall-Profile without a change record restores the oldest backup and keeps the current file (F-01)' { + $path = New-WtSettings -Json '{"profiles":{"defaults":{"opacity":75}}}' + [System.IO.File]::WriteAllText("$path.20240101-000000.bak", '{"profiles":{"defaults":{"opacity":88}}}', $utf8) + (Get-Item -LiteralPath "$path.20240101-000000.bak").LastWriteTime = (Get-Date).AddDays(-10) + [System.IO.File]::WriteAllText("$path.20240601-000000.bak", '{"profiles":{"defaults":{"opacity":60}}}', $utf8) + Uninstall-Profile -Confirm:$false 6>$null | Out-Null + Assert-True ((Read-JsonFile $path).profiles.defaults.opacity -eq 88) 'did not restore the oldest (pre-install) backup' + Assert-True (@(Get-ChildItem -LiteralPath (Split-Path $path) -Filter 'settings.json.pre-uninstall-*.json').Count -eq 1) 'current settings were not kept' +} + +Test-Case 'windowsTerminal.manage = false leaves settings.json alone in uninstall and Set-TerminalBackground (F-05)' { + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path (Get-PspCacheDir) 'user-settings.json'), '{ "windowsTerminal": { "manage": false } }', $utf8) + $path = New-WtSettings -Json '{"profiles":{"defaults":{"opacity":75}}}' + [System.IO.File]::WriteAllText("$path.20240101-000000.bak", '{"stale":true}', $utf8) + $before = [System.IO.File]::ReadAllText($path) + $img = Join-Path $script:Sandbox.Work 'bg.png' + [System.IO.File]::WriteAllBytes($img, [byte[]](0x89, 0x50, 0x4E, 0x47)) + Set-TerminalBackground -Path $img -Confirm:$false 6>$null + Uninstall-Profile -Confirm:$false 6>$null | Out-Null + Uninstall-Profile -HardResetWindowsTerminal -Confirm:$false 6>$null | Out-Null + Assert-True ((Test-Path -LiteralPath $path) -and [System.IO.File]::ReadAllText($path) -eq $before) 'settings.json was changed or deleted' +} + +Test-Case 'Set-TerminalBackground goes through the change record and -Clear reverts cleanly' { + $path = New-WtSettings -Json '{ "profiles": { "defaults": { "backgroundImageOpacity": 0.8 }, "list": [] } }' + $img = Join-Path $script:Sandbox.Work 'bg.png' + [System.IO.File]::WriteAllBytes($img, [byte[]](0x89, 0x50, 0x4E, 0x47)) + Set-TerminalBackground -Path $img -Alignment left -Confirm:$false 6>$null + $wt = Read-JsonFile $path + Assert-True ($wt.profiles.defaults.backgroundImage -eq $img -and $wt.profiles.defaults.backgroundImageAlignment -eq 'left') 'background not applied' + Set-TerminalBackground -Clear -Confirm:$false 6>$null + $null = Undo-WindowsTerminalSettingsFile -Path $path -ManifestPath (Join-Path (Get-PspCacheDir) 'wt-changes.json') -Confirm:$false + $wt = Read-JsonFile $path + Assert-True ($wt.profiles.defaults.backgroundImageOpacity -eq 0.8 -and -not (Test-PspProp $wt.profiles.defaults 'backgroundImage')) 'original values not restored' +} + +Write-Host '-- Trusted .psprc.ps1 (F-02) --' -ForegroundColor Magenta + +Test-Case 'A trusted .psprc.ps1 runs only while its content matches the approved hash' { + $repo = Join-Path $script:Sandbox.Work 'repo' + New-Item -ItemType Directory -Path $repo -Force | Out-Null + $rc = Join-Path $repo '.psprc.ps1' + [System.IO.File]::WriteAllText($rc, '$env:PSP_RC_RAN = "v1"', $utf8) + $script:PSP.Features.perDirProfiles = $true + $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear() + $env:PSP_RC_RAN = $null + Add-TrustedDirectory -Path $repo -Confirm:$false 6>$null | Out-Null + $saved = Read-JsonFile (Join-Path (Get-PspCacheDir) 'user-settings.json') + Assert-True ((Get-PspProp $saved.trustedRcHashes $repo) -eq (Get-PspRcHash -Directory $repo)) 'approved hash not persisted' + Push-Location $repo + try { + $script:PSP.LastPwd = $null; Invoke-PromptStage 6>$null + Assert-True ($env:PSP_RC_RAN -eq 'v1') 'approved file did not run' + # A git pull changes the file. + [System.IO.File]::WriteAllText($rc, '$env:PSP_RC_RAN = "v2"', $utf8) + $env:PSP_RC_RAN = $null + $script:PSP.LastPwd = $null + $warnings = @(Invoke-PromptStage 6>$null 3>&1) + Assert-True ($null -eq $env:PSP_RC_RAN) 'changed file ran without approval' + Assert-True ((@($warnings) -join ' ') -match 'changed since it was approved') 'no warning for the changed file' + Add-TrustedDirectory -Path $repo -Confirm:$false 6>$null | Out-Null + $env:PSP_RC_RAN = $null + $script:PSP.LastPwd = $null; Invoke-PromptStage 6>$null + Assert-True ($env:PSP_RC_RAN -eq 'v2') 're-approved file did not run' + } + finally { Pop-Location; $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear(); $env:PSP_RC_RAN = $null } +} + +Test-Case 'A trusted directory from an older version (no hash) does not run its .psprc.ps1' { + $dir = Join-Path $script:Sandbox.Work 'legacy' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $dir '.psprc.ps1'), '$env:PSP_LEGACY_RAN = "yes"', $utf8) + $script:PSP.Features.perDirProfiles = $true + $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear() + [void]$script:PSP.TrustedDirs.Add($dir) + $env:PSP_LEGACY_RAN = $null + Push-Location $dir + try { $script:PSP.LastPwd = $null; Invoke-PromptStage 6>$null 3>$null } + finally { Pop-Location; $script:PSP.TrustedDirs.Clear() } + Assert-True ($null -eq $env:PSP_LEGACY_RAN) 'legacy trust entry ran unapproved code' +} + +Test-Case 'Trust changes never overwrite an unparseable user-settings.json and roll back in memory' { + $repo = Join-Path $script:Sandbox.Work 'repo2' + New-Item -ItemType Directory -Path $repo -Force | Out-Null + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $settings = Join-Path (Get-PspCacheDir) 'user-settings.json' + [System.IO.File]::WriteAllText($settings, '{ "trustedDirs": [ ', $utf8) + $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear() + Add-TrustedDirectory -Path $repo -Confirm:$false -ErrorAction SilentlyContinue 6>$null 3>$null | Out-Null + Assert-True ([System.IO.File]::ReadAllText($settings) -eq '{ "trustedDirs": [ ') 'corrupt settings were overwritten' + Assert-True ($script:PSP.TrustedDirs.Count -eq 0 -and $script:PSP.TrustedRcHashes.Count -eq 0) 'in-memory trust not rolled back' +} + +Test-Case 'Trust changes keep entries another session saved since this one loaded' { + $a = Join-Path $script:Sandbox.Work 'trust-a' + $b = Join-Path $script:Sandbox.Work 'trust-b' + foreach ($d in $a, $b) { New-Item -ItemType Directory -Path $d -Force | Out-Null } + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $settings = Join-Path (Get-PspCacheDir) 'user-settings.json' + $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear() + try { + # Another session trusted $a after this one loaded. + [System.IO.File]::WriteAllText($settings, (@{ trustedDirs = @($a); trustedRcHashes = @{ $a = '' } } | ConvertTo-Json), $utf8) + Add-TrustedDirectory -Path $b -Confirm:$false 6>$null | Out-Null + $saved = @((Read-JsonFile $settings).trustedDirs) + Assert-True ($saved -contains $a -and $saved -contains $b) "entries lost: $($saved -join ', ')" + Assert-True ($script:PSP.TrustedDirs -contains $a) 'this session did not pick up the other entry' + Remove-TrustedDirectory -Path $b -Confirm:$false 6>$null | Out-Null + $saved = @((Read-JsonFile $settings).trustedDirs) + Assert-True ($saved -contains $a -and $saved -notcontains $b) "untrust result: $($saved -join ', ')" + } + finally { $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear() } +} + +Test-Case 'An approved .psprc.ps1 runs the text that was checked and sees its folder as $PSScriptRoot' { + $repo = Join-Path $script:Sandbox.Work 'rcroot' + New-Item -ItemType Directory -Path $repo -Force | Out-Null + $rc = Join-Path $repo '.psprc.ps1' + $approved = 'param()' + "`r`n" + '$env:PSP_RC_RAN = "approved|$PSScriptRoot"' + [System.IO.File]::WriteAllText($rc, $approved, $utf8) + $script:PSP.Features.perDirProfiles = $true + $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear() + $env:PSP_RC_RAN = $null + try { + Add-TrustedDirectory -Path $repo -Confirm:$false 6>$null | Out-Null + $approvedContent = Get-PspRcContent -Directory $repo + # The file is swapped between the check and the run: only the checked text may run. + [System.IO.File]::WriteAllText($rc, '$env:PSP_RC_RAN = "swapped"', $utf8) + function Get-PspRcContent { param([string]$Directory) $approvedContent } + Push-Location $repo + try { $script:PSP.LastPwd = $null; Invoke-PromptStage 6>$null 3>$null } + finally { Pop-Location } + Assert-True ($env:PSP_RC_RAN -eq "approved|$repo") "ran: '$env:PSP_RC_RAN'" + } + finally { $script:PSP.TrustedDirs.Clear(); $script:PSP.TrustedRcHashes.Clear(); $env:PSP_RC_RAN = $null } +} + +Write-Host '-- Update-Profile and setup.ps1 verification (F-03, F-08) --' -ForegroundColor Magenta + +Test-Case 'Update-Profile stops without a hash and on a wrong hash, writing nothing' { + Install-DownloadStub + $err = $null + Update-Profile -Ref main -ErrorAction SilentlyContinue -ErrorVariable err 6>$null | Out-Null + Assert-True ((@($err) -join ' ') -match 'Hash input required') 'missing hash was not rejected' + $err = $null + Update-Profile -Ref main -ExpectedSha256 ('0' * 64) -ErrorAction SilentlyContinue -ErrorVariable err 6>$null | Out-Null + Assert-True ((@($err) -join ' ') -match 'mismatch') 'wrong hash was not rejected' + Assert-True (-not (Test-Path -LiteralPath $PROFILE)) 'profile written despite failed verification' + Assert-True (@(Get-ChildItem -LiteralPath $env:TEMP -Filter 'psp-*' -Force).Count -eq 0) 'temp files left behind' +} + +Test-Case 'Update-Profile -Ref vX.Y.Z checks the release SHA256SUMS.txt and installs no tools by default' { + Install-DownloadStub + $global:PspStubSums = (@('Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json') | ForEach-Object { '{0} {1}' -f (Get-CommittedSha256 $_).ToLowerInvariant(), $_ }) -join "`n" + $err = $null + $script:Installs = @() + function winget { $script:Installs += "winget $args"; $global:LASTEXITCODE = 0 } + function Install-Module { $script:Installs += "Install-Module $args" } + $out = Update-Profile -Ref v9.9.9 -ErrorAction SilentlyContinue -ErrorVariable err *>&1 | Out-String + # -ErrorVariable also collects errors that nested calls silenced; only the update's own failure counts. + $failure = @($err | Where-Object { "$_" -match 'Unable to check for' }) + Assert-True ($failure.Count -eq 0) "update failed: $($failure -join ' ')" + Assert-True ($out -match 'match the SHA256SUMS.txt of release v9.9.9') 'release verification not reported' + Assert-True (Test-Path -LiteralPath $PROFILE) 'profile not installed' + Assert-True ($script:Installs.Count -eq 0 -and @(Get-PspSandboxWingetCalls -Sandbox $script:Sandbox).Count -eq 0) "Update-Profile installed software without -InstallMissingTools: $($script:Installs -join ' | ')" + $state = Read-JsonFile (Join-Path (Get-PspCacheDir) 'installed.json') + Assert-True ($state.ref -eq 'v9.9.9' -and $state.commit -eq ('a' * 40)) 'install state not recorded' + # A tampered file is rejected. + $global:PspStubSums = $global:PspStubSums -replace '^[0-9a-f]{64}', ('f' * 64) + Remove-Item -LiteralPath $PROFILE -Force + $err = $null + Update-Profile -Ref v9.9.9 -Force -ErrorAction SilentlyContinue -ErrorVariable err 6>$null | Out-Null + Assert-True ((@($err) -join ' ') -match 'does not match the SHA256SUMS.txt') 'tampered release file accepted' + Assert-True (-not (Test-Path -LiteralPath $PROFILE)) 'tampered profile installed' + $global:PspStubSums = $null +} + +Test-Case 'setup.ps1 verifies the bundle before any change (missing, wrong, pinned and release hashes)' { + Install-DownloadStub + $out = & $setupPath -Ref main -CiMode -SkipWizard *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 1 -and $out -match 'Hash input required') 'missing hash accepted' + Assert-True (-not (Test-Path -LiteralPath (Get-PspCacheDir)) -and -not (Test-Path -LiteralPath $PROFILE)) 'changes made before verification' + $combined = [regex]::Match($out, 'combined:\s+([0-9A-F]{64})').Groups[1].Value + $out = & $setupPath -Ref main -CiMode -SkipWizard -ExpectedSha256 ('0' * 64) *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 1 -and $out -match 'mismatch' -and -not (Test-Path -LiteralPath $PROFILE)) 'wrong hash accepted' + $out = & $setupPath -Ref main -CiMode -SkipWizard -ExpectedSha256 $combined *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $PROFILE)) "pinned install failed: $out" + Assert-True (@(Get-ChildItem -LiteralPath $env:TEMP -Filter 'psp-*' -Force).Count -eq 0) 'temp files left behind' +} + +Test-Case 'Update-Profile -InstallMissingTools installs missing tools even when the profile is up to date' { + Install-DownloadStub + $global:PspStubSums = (@('Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json') | ForEach-Object { '{0} {1}' -f (Get-CommittedSha256 $_).ToLowerInvariant(), $_ }) -join "`n" + try { + $err = $null + Update-Profile -Ref main -InstallMissingTools -ErrorAction SilentlyContinue -ErrorVariable err 6>$null | Out-Null + Assert-True ((@($err) -join ' ') -match "ExpectedSha256 '[0-9A-F]{64}' -InstallMissingTools") 'the re-run hint drops -InstallMissingTools' + Update-Profile -Ref v9.9.9 -ErrorAction SilentlyContinue *>&1 | Out-Null + Assert-True (Test-Path -LiteralPath $PROFILE) 'first update did not install the profile' + $script:WingetCalls = @() + function winget { $script:WingetCalls += ($args -join ' '); $global:LASTEXITCODE = 0 } + function Get-ProfileToolExecutablePath { param($Tool) $null } + $out = Update-Profile -Ref v9.9.9 -InstallMissingTools -ErrorAction SilentlyContinue *>&1 | Out-String + $installs = @($script:WingetCalls | Where-Object { $_ -match '^install -e --id ' }) + Assert-True ($installs.Count -eq @($script:ProfileTools).Count) "missing tools not installed on an up-to-date profile: $($script:WingetCalls -join ' | ') $out" + } + finally { $global:PspStubSums = $null } +} + +Test-Case 'An unreadable user-settings.json leaves settings.json alone in Update-Profile' { + Install-DownloadStub + $global:PspStubSums = (@('Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json') | ForEach-Object { '{0} {1}' -f (Get-CommittedSha256 $_).ToLowerInvariant(), $_ }) -join "`n" + try { + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path (Get-PspCacheDir) 'user-settings.json'), '{ "windowsTerminal": ', $utf8) + $path = New-WtSettings + $before = [System.IO.File]::ReadAllText($path) + Update-Profile -Ref v9.9.9 -ErrorAction SilentlyContinue *>&1 | Out-Null + Assert-True (Test-Path -LiteralPath $PROFILE) 'profile not installed' + Assert-True ([System.IO.File]::ReadAllText($path) -eq $before) 'settings.json was changed' + } + finally { $global:PspStubSums = $null } +} + +Test-Case 'winutil runs a pinned or forced download only from an elevated shell' { + $script:Downloads = 0 + function Invoke-WebRequest { $script:Downloads++; [PSCustomObject]@{ RawContentStream = [System.IO.MemoryStream]::new([byte[]](0x23)) } } + $saved = $script:PSP.IsAdmin + $script:PSP.IsAdmin = $false + try { + foreach ($call in @(@{ Force = $true }, @{ ExpectedSha256 = ('0' * 64) })) { + $err = $null + winutil @call -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable err 6>$null + Assert-True ((@($err) -join ' ') -match 'elevated shell' -and $script:Downloads -eq 0) "winutil -$(@($call.Keys)[0]) went ahead unelevated" + } + } + finally { $script:PSP.IsAdmin = $saved } +} + +Test-Case 'Invoke-ProfileWizard checks setup.ps1 of a release and passes the tag on' { + Install-DownloadStub + $global:PspStubSums = (@('setup.ps1', 'Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json') | ForEach-Object { '{0} {1}' -f (Get-CommittedSha256 $_).ToLowerInvariant(), $_ }) -join "`n" + $script:ShellArgs = $null + function Get-PspShellPath { 'Invoke-PspTestShell' } + function Invoke-PspTestShell { $script:ShellArgs = @($args); $global:LASTEXITCODE = 0 } + try { + Invoke-ProfileWizard -Ref v9.9.9 -Confirm:$false 6>$null + $i = [array]::IndexOf([object[]]$script:ShellArgs, '-Ref') + Assert-True ($i -ge 0 -and $script:ShellArgs[$i + 1] -eq 'v9.9.9') "setup.ps1 not started with the release tag: $($script:ShellArgs -join ' ')" + $global:PspStubSums = $global:PspStubSums -replace '(?m)^[0-9a-f]{64}(?= setup\.ps1$)', ('f' * 64) + $script:ShellArgs = $null + $err = $null + Invoke-ProfileWizard -Ref v9.9.9 -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable err 6>$null + Assert-True ($null -eq $script:ShellArgs -and (@($err) -join ' ') -match 'does not match the SHA256SUMS.txt') 'a setup.ps1 that differs from the release was run' + } + finally { $global:PspStubSums = $null } +} + +Test-Case 'setup.ps1 -Ref vX.Y.Z verifies the bundle against the release SHA256SUMS.txt' { + Install-DownloadStub + $global:PspStubSums = (@('Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json') | ForEach-Object { '{0} {1}' -f (Get-CommittedSha256 $_).ToLowerInvariant(), $_ }) -join "`n" + try { + $out = & $setupPath -Ref v9.9.9 -CiMode -SkipWizard *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 0 -and $out -match 'matches the SHA256SUMS.txt of release v9.9.9' -and (Test-Path -LiteralPath $PROFILE)) "release install failed: $out" + Remove-Item -LiteralPath $PROFILE -Force + $global:PspStubSums = $global:PspStubSums -replace '(?m)^[0-9a-f]{64}(?= theme\.json$)', ('f' * 64) + $out = & $setupPath -Ref v9.9.9 -CiMode -SkipWizard *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 1 -and $out -match 'does not match the SHA256SUMS.txt' -and -not (Test-Path -LiteralPath $PROFILE)) 'a bundle that differs from the release was installed' + } + finally { $global:PspStubSums = $null } +} + +Test-Case 'setup.ps1 run as a script block leaves no variables in the caller''s session' { + $q = { param($s) "'" + ($s -replace "'", "''") + "'" } + $code = @( + "`$global:PROFILE = $(& $q $PROFILE)" + "& ([scriptblock]::Create([System.IO.File]::ReadAllText($(& $q $setupPath)))) -LocalRepo $(& $q $repoRoot) -CiMode -SkipWizard -WhatIf *> `$null" + "`$left = @('Bundle', 'WorkDir', 'CanPrompt', 'SharedFunctionNames', 'CuratedFonts', 'IsCiHost') | Where-Object { Get-Variable -Name `$_ -Scope Global -ErrorAction Ignore }" + "'LEFT=' + (`$left -join ',') + ';EXIT=' + `$LASTEXITCODE" + ) -join "`n" + $exe = if ($PSVersionTable.PSVersion.Major -ge 6) { 'pwsh' } else { 'powershell' } + $out = & $exe -NoProfile -NonInteractive -Command $code 2>&1 | Out-String + Assert-True ($out -match 'LEFT=;EXIT=0') "unexpected: $out" +} + +Test-Case 'setup.ps1 saves the user''s own profile as profile.original.ps1, never an installed copy' { + Install-DownloadStub + $d = $script:Sandbox.Ps7Dir + # An installed copy of this profile is current; the user's own profile only survives as an old backup. + [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), [System.IO.File]::ReadAllText($profilePath), $utf8) + $own = Join-Path $d 'oldprofile.20240101-000000.ps1' + [System.IO.File]::WriteAllText($own, '# my own profile', $utf8) + (Get-Item -LiteralPath $own).LastWriteTime = (Get-Date).AddDays(-30) + $out = & $setupPath -LocalRepo $repoRoot -CiMode -SkipWizard *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 0) "setup failed: $out" + Assert-True ([System.IO.File]::ReadAllText((Join-Path $d 'profile.original.ps1')).Trim() -eq '# my own profile') 'profile.original.ps1 is not the user''s own profile' +} + +Test-Case 'setup.ps1 loads every profile helper its shared functions call' { + $tokens = $null; $parseErrors = $null + $profileAst = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $setupAst = [System.Management.Automation.Language.Parser]::ParseFile($setupPath, [ref]$tokens, [ref]$parseErrors) + $profileFns = @{} + foreach ($s in $profileAst.EndBlock.Statements) { if ($s -is [System.Management.Automation.Language.FunctionDefinitionAst]) { $profileFns[$s.Name] = $s } } + $available = @{} + foreach ($f in $setupAst.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { $available[$f.Name] = $true } + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $setupPath -Variable '$script:SharedFunctionNames'))) + $shared = @($script:SharedFunctionNames) + foreach ($n in $shared) { $available[$n] = $true } + $missing = @() + foreach ($n in $shared) { + Assert-True ($profileFns.ContainsKey($n)) "the profile does not define $n" + foreach ($c in $profileFns[$n].FindAll({ param($x) $x -is [System.Management.Automation.Language.CommandAst] }, $true)) { + $called = $c.GetCommandName() + if ($called -and $profileFns.ContainsKey($called) -and -not $available[$called]) { $missing += "$called (called by $n)" } + } + } + Assert-True ($missing.Count -eq 0) "add to `$script:SharedFunctionNames in setup.ps1: $($missing | Sort-Object -Unique)" +} + +Test-Case 'setup.ps1 -LocalRepo -CiMode installs into the sandbox; -WhatIf changes nothing (F-14)' { + # Downloads (the Oh My Posh theme) fail as if offline. + Install-DownloadStub + $wt = New-WtSettings + $out = & $setupPath -LocalRepo $repoRoot -CiMode -SkipWizard -WhatIf *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 0 -and -not (Test-Path -LiteralPath $PROFILE) -and -not (Test-Path -LiteralPath (Get-PspCacheDir))) "-WhatIf changed something: $out" + $out = & $setupPath -LocalRepo $repoRoot -CiMode -SkipWizard *>&1 | Out-String + Assert-True ($LASTEXITCODE -eq 0) "setup failed: $out" + foreach ($f in 'theme.json', 'terminal-config.json', 'user-settings.json', 'installed.json', 'wt-changes.json') { + Assert-True (Test-Path -LiteralPath (Join-Path (Get-PspCacheDir) $f)) "cache is missing $f" + } + Assert-True ((Read-JsonFile $wt).profiles.defaults.font.face -eq 'CaskaydiaCove NF') 'terminal defaults not applied' + Assert-True (@(Get-ChildItem -LiteralPath (Join-Path (Get-PspCacheDir) 'logs') -Filter 'setup-*.log').Count -eq 1) 'no setup log' + Assert-True ($out -notmatch 'Terminal-Icons') 'Terminal-Icons touched in CI mode (F-07)' +} + +Write-Host '-- Wizard input (F-06, F-21) --' -ForegroundColor Magenta + +Test-Case 'Save-WizardChoices refuses an unparseable file and keeps hand-edited JSONC keys' { + $path = Join-Path $script:Sandbox.Work 'user-settings.json' + [System.IO.File]::WriteAllText($path, '{ "trustedDirs": [ ', $utf8) + $threw = $false + try { Save-WizardChoices -Choices @{ Features = [ordered]@{ psfzf = $false } } -UserSettingsPath $path -Confirm:$false } catch { $threw = $true } + Assert-True ($threw -and [System.IO.File]::ReadAllText($path) -eq '{ "trustedDirs": [ ') 'unparseable settings were overwritten' + $jsonc = "{`n // mine`n `"trustedDirs`": [`"C:\\work`"],`n `"commandOverrides`": { `"gs`": `"git status`" },`n}" + [System.IO.File]::WriteAllText($path, $jsonc, $utf8) + Save-WizardChoices -Choices @{ Features = [ordered]@{ psfzf = $false }; Terminal = [ordered]@{ opacity = 90; fontSize = 12 } } -UserSettingsPath $path -Confirm:$false + $s = Read-JsonFile $path + Assert-True (@($s.trustedDirs) -contains 'C:\work' -and $s.commandOverrides.gs -eq 'git status') 'existing keys lost' + Assert-True ($s.features.psfzf -eq $false -and $s.defaults.opacity -eq 90 -and $s.defaults.font.size -eq 12) 'choices not saved' + Assert-True ([System.IO.File]::ReadAllText("$path.bak") -eq $jsonc) 'no backup of the previous file' +} + +Test-Case 'Resumed wizard state is rebuilt from the curated lists (tampered values dropped)' { + $saved = [PSCustomObject]@{ + Theme = [PSCustomObject]@{ Name = 'pure'; Url = 'http://evil.example/x.omp.json' } + Scheme = [PSCustomObject]@{ name = 'Dracula'; background = '#ff0000' } + Font = [PSCustomObject]@{ Name = 'CascadiaCode'; Asset = '../../evil'; Sha256 = ('0' * 64) } + Editor = "code'; Remove-Item C:\ -Recurse; '" + TabBar = 'red' + AppTheme = 'light' + Terminal = [PSCustomObject]@{ opacity = 500; fontSize = 12; cursorShape = 'bar; rm' } + Features = [PSCustomObject]@{ psfzf = $false; evil = $true } + CompletedSteps = @('Theme', 'Bogus') + } + $v = Resolve-WizardState -Saved $saved + Assert-True ($null -eq $v.Theme) 'untrusted theme URL kept' + Assert-True ($v.Scheme.background -eq '#282a36') 'scheme not replaced by the curated definition' + Assert-True ($v.Font.Asset -eq 'CascadiaCode' -and $v.Font.Sha256 -ne ('0' * 64)) 'font asset or hash taken from the state file' + Assert-True ($null -eq $v.Editor -and $null -eq $v.TabBar -and $v.AppTheme -eq 'light') 'editor/tab bar validation' + Assert-True ($v.Terminal.Count -eq 1 -and $v.Terminal['fontSize'] -eq 12) 'terminal values not range-checked' + Assert-True ($v.Features.Count -eq 1 -and $v.Features['psfzf'] -eq $false) 'unknown feature kept' + Assert-True (@($v.CompletedSteps).Count -eq 1) 'unknown step kept' +} + +# Answer the wizard's Read-Host prompts from a queue; '' simulates an interrupted run (Ctrl+C). +function New-WizardAnswers { + param([string[]]$Answers) + $script:WizardAnswers = [System.Collections.Generic.Queue[string]]::new() + foreach ($a in $Answers) { $script:WizardAnswers.Enqueue($a) } +} + +Test-Case 'Wizard quick start saves the pinned preset' { + function Read-Host { param($Prompt, [switch]$AsSecureString) if ($script:WizardAnswers.Count -eq 0) { throw "unexpected prompt: $Prompt" }; $script:WizardAnswers.Dequeue() } + function Test-TelemetryOptOutSet { $false } + New-WizardAnswers @('y', '') + $state = Join-Path (Get-PspCacheDir) 'wizard-state.json' + $choices = Start-InstallWizard -StatePath $state 6>$null + Assert-True ($choices.Theme.Name -eq 'atomic' -and $choices.Theme.Sha256 -eq $script:PinnedOmpThemes['atomic']) 'preset theme not pinned' + Assert-True ($choices.Theme.Url -match '/v31\.8\.0/themes/atomic\.omp\.json$') 'preset theme URL not pinned' + Assert-True ($choices.Scheme.name -eq 'Tokyo Night' -and $choices.Font.Asset -eq 'CascadiaCode') 'preset scheme or font missing' + Assert-True ($script:WizardAnswers.Count -eq 0) 'wizard asked fewer questions than expected' + $settings = Join-Path (Get-PspCacheDir) 'user-settings.json' + Save-WizardChoices -Choices $choices -UserSettingsPath $settings -Confirm:$false + $saved = Read-JsonFile $settings + Assert-True ($saved.theme.name -eq 'atomic' -and $saved.theme.sha256 -eq $script:PinnedOmpThemes['atomic'] -and $saved.defaults.font.face -eq 'CaskaydiaCove NF') 'preset not saved' +} + +Test-Case 'An interrupted wizard resumes from its saved state' { + function Read-Host { param($Prompt, [switch]$AsSecureString) if ($script:WizardAnswers.Count -eq 0) { throw "unexpected prompt: $Prompt" }; $a = $script:WizardAnswers.Dequeue(); if ($a -eq '') { throw 'interrupted' }; $a } + function Test-TelemetryOptOutSet { $false } + function Get-OmpThemeList { @(@{ Name = 'atomic'; Desc = ''; Url = (Get-OmpThemeUrl -Name 'atomic'); Sha256 = $script:PinnedOmpThemes['atomic'] }, @{ Name = 'pure'; Desc = ''; Url = (Get-OmpThemeUrl -Name 'pure'); Sha256 = $script:PinnedOmpThemes['pure'] }) } + $state = Join-Path (Get-PspCacheDir) 'wizard-state.json' + # No quick start; theme, scheme, font, tab bar (default), dark chrome; stop at the first terminal question. + New-WizardAnswers @('n', 'atomic', 'Nord', 'Hack', '', 'n', '') + $threw = $false + try { $null = Start-InstallWizard -StatePath $state 6>$null } catch { $threw = $_.Exception.Message -eq 'interrupted' } + Assert-True $threw 'interruption not raised' + Assert-True (Test-Path -LiteralPath $state) 'no state saved' + # Resume; then terminal (opacity 90, no acrylic, size 12, defaults), PSReadLine, no background, default editor, no telemetry, 5 features, apply. + New-WizardAnswers @('y', '90', 'n', '12', '', '', '', '', '', 'n', '', 'n', '', '', '', '', '', '') + $choices = Start-InstallWizard -StatePath $state 6>$null + Assert-True ($choices.Theme.Name -eq 'atomic' -and $choices.Scheme.name -eq 'Nord' -and $choices.Font.Asset -eq 'Hack') 'choices made before the interruption were lost' + Assert-True ($choices.Terminal['opacity'] -eq 90 -and $choices.Terminal['fontSize'] -eq 12) 'choices after resume missing' + Assert-True ($choices.TelemetryOptOut -eq $false -and $choices.Features['psfzf'] -eq $true) 'telemetry or feature answers wrong' + Assert-True ($script:WizardAnswers.Count -eq 0) 'wizard asked fewer questions than expected' +} +Test-Case 'Wizard quick start picks an installed editor instead of installing one' { + function Read-Host { param($Prompt, [switch]$AsSecureString) if ($script:WizardAnswers.Count -eq 0) { throw "unexpected prompt: $Prompt" }; $script:WizardAnswers.Dequeue() } + function Test-TelemetryOptOutSet { $false } + # Only vim is installed. + function Get-Command { param([string]$Name, $ErrorAction) if ($Name -eq 'vim') { [PSCustomObject]@{ Name = 'vim' } } } + New-WizardAnswers @('y', '') + $choices = Start-InstallWizard -StatePath (Join-Path (Get-PspCacheDir) 'wizard-state.json') 6>$null + Assert-True ($choices.Editor -eq 'vim') "preset editor is '$($choices.Editor)'" +} + +Test-Case 'Wizard name matching treats wildcard characters literally' { + function Read-Host { param($Prompt, [switch]$AsSecureString) if ($script:WizardAnswers.Count -eq 0) { throw "unexpected prompt: $Prompt" }; $script:WizardAnswers.Dequeue() } + New-WizardAnswers @('[', '2') + $item = Select-WizardItem -Title 'pick' -Items @(@{ Name = 'alpha'; Desc = '' }, @{ Name = 'beta'; Desc = '' }) 6>$null + Assert-True ($item.Name -eq 'beta') 'a reply with [ broke the selection' +} + +Test-Case 'Set-PreferredEditorInProfiles quotes the name, backs up, and leaves an unchanged file alone' { + $dir = $script:Sandbox.Ps7Dir + $userProfile = Join-Path $dir 'profile_user.ps1' + [System.IO.File]::WriteAllText($userProfile, "# mine`r`n`$script:EditorPriority = @('vim', 'notepad')`r`n# end`r`n", $utf8) + Set-PreferredEditorInProfiles -EditorName "o'brien" -CommentLabel 't' -ProfileDirs @($dir) -Confirm:$false + $text = [System.IO.File]::ReadAllText($userProfile) + $tokens = $null; $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$tokens, [ref]$parseErrors) + Assert-True ($parseErrors.Count -eq 0 -and $text -match "@\('o''brien', 'notepad'\)") 'editor name not quoted safely' + Assert-True ($text -match "# end`r`n" -and $text -notmatch "notepad'\)`n") 'line endings damaged' + Assert-True (Test-Path -LiteralPath "$userProfile.bak") 'no backup' + $stamp = (Get-Item -LiteralPath $userProfile).LastWriteTimeUtc + Start-Sleep -Milliseconds 50 + Set-PreferredEditorInProfiles -EditorName "o'brien" -CommentLabel 't' -ProfileDirs @($dir) -Confirm:$false + Assert-True ((Get-Item -LiteralPath $userProfile).LastWriteTimeUtc -eq $stamp) 'unchanged file was rewritten' +} + +Test-Case 'Install-OhMyPoshTheme rejects a theme whose hash differs and invalid names (F-11)' { + Install-DownloadStub + $global:PspStubTheme = '{ "blocks": [] }' + $url = Get-OmpThemeUrl -Name 'pure' + Assert-True (-not (Install-OhMyPoshTheme -ThemeName 'pure' -ThemeUrl $url -Sha256 ('0' * 64) 6>$null)) 'mismatching theme accepted' + Assert-True (-not (Test-Path -LiteralPath (Join-Path (Get-PspCacheDir) 'pure.omp.json'))) 'mismatching theme written' + $good = (Get-FileHash -InputStream ([System.IO.MemoryStream]::new($utf8.GetBytes($global:PspStubTheme))) -Algorithm SHA256).Hash + Assert-True (Install-OhMyPoshTheme -ThemeName 'pure' -ThemeUrl $url -Sha256 $good 6>$null) 'matching theme rejected' + Assert-True (-not (Install-OhMyPoshTheme -ThemeName '..\evil' -ThemeUrl $url 6>$null)) 'path-like theme name accepted' + $global:PspStubTheme = $null +} + +Write-Host '-- Cache, uninstall leftovers and state (F-16, F-17) --' -ForegroundColor Magenta + +Test-Case 'Clear-ProfileCache keeps settings and state files' { + $cache = Get-PspCacheDir + New-Item -ItemType Directory -Path (Join-Path $cache 'plugins') -Force | Out-Null + foreach ($f in 'user-settings.json', 'telemetry.owned', 'wt-changes.json', 'installed.json', 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'bg-1a2b3c.png') { + [System.IO.File]::WriteAllText((Join-Path $cache $f), '{}', $utf8) + } + Clear-ProfileCache -Confirm:$false 6>$null + foreach ($f in 'user-settings.json', 'telemetry.owned', 'wt-changes.json', 'installed.json', 'plugins', 'bg-1a2b3c.png') { Assert-True (Test-Path -LiteralPath (Join-Path $cache $f)) "$f was removed" } + foreach ($f in 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json') { Assert-True (-not (Test-Path -LiteralPath (Join-Path $cache $f))) "$f was kept" } +} + +Test-Case 'Uninstall-Profile -RemoveUserData removes profile backups; a plain uninstall lists them' { + foreach ($d in $script:Sandbox.Ps7Dir, $script:Sandbox.Ps5Dir) { + New-Item -ItemType Directory -Path $d -Force | Out-Null + foreach ($f in 'Microsoft.PowerShell_profile.ps1', 'oldprofile.20240101-000000.ps1', 'profile_user.ps1') { [System.IO.File]::WriteAllText((Join-Path $d $f), '# x', $utf8) } + } + $out = Uninstall-Profile -Confirm:$false 6>&1 | Out-String + Assert-True ($out -match 'oldprofile') 'kept profile backups are not reported' + Assert-True (Test-Path -LiteralPath (Join-Path $script:Sandbox.Ps7Dir 'oldprofile.20240101-000000.ps1')) 'backup removed without -RemoveUserData' + Uninstall-Profile -RemoveUserData -Confirm:$false 6>$null | Out-Null + foreach ($d in $script:Sandbox.Ps7Dir, $script:Sandbox.Ps5Dir) { + Assert-True (@(Get-ChildItem -LiteralPath $d -Filter 'oldprofile*.ps1' -ErrorAction SilentlyContinue).Count -eq 0) "backups left in $d" + } +} + +Test-Case 'Uninstall-Profile never restores a profile.original.ps1 that is a copy of this profile' { + $d = $script:Sandbox.Ps7Dir + $installed = '### PowerShell Profile (26zl)' + "`r`n# installed copy" + foreach ($f in 'Microsoft.PowerShell_profile.ps1', 'profile.original.ps1') { [System.IO.File]::WriteAllText((Join-Path $d $f), $installed, $utf8) } + $own = Join-Path $d 'oldprofile.20240101-000000.ps1' + [System.IO.File]::WriteAllText($own, '# my own profile', $utf8) + Uninstall-Profile -Confirm:$false 6>$null 3>$null | Out-Null + Assert-True (-not (Test-Path -LiteralPath (Join-Path $d 'Microsoft.PowerShell_profile.ps1'))) 'this profile was left installed' + Assert-True (Test-Path -LiteralPath $own) 'the backup of the user''s own profile was deleted' +} + +Test-Case 'Set-PspInstallState drops a value that is passed empty' { + $cache = Get-PspCacheDir + Set-PspInstallState -CacheDir $cache -Version '1.0.0' -Commit ('b' * 40) -Source 'remote' + Set-PspInstallState -CacheDir $cache -Version '1.0.1' -Commit '' -Source 'local' + $state = Read-JsonFile (Join-Path $cache 'installed.json') + Assert-True ($state.version -eq '1.0.1' -and $state.source -eq 'local' -and -not (Test-PspProp $state 'commit')) "stale value kept: $($state | ConvertTo-Json -Compress)" +} + +Test-Case 'Write-Utf8FileAtomic writes completely when the caller runs with -WhatIf' { + $path = Join-Path $script:Sandbox.Work 'atomic.txt' + [System.IO.File]::WriteAllText($path, 'old', $utf8) + & { $WhatIfPreference = $true; Write-Utf8FileAtomic -Path $path -Content 'new' -BackupPath "$path.bak" } + Assert-True ([System.IO.File]::ReadAllText($path) -eq 'new') 'file not written' + Assert-True (@(Get-ChildItem -LiteralPath $script:Sandbox.Work -Filter '.psp-tmp-*' -Force).Count -eq 0) 'temporary file left behind' +} + +Test-Case 'Uninstall-Profile -RemoveFonts uses the font named in the cached terminal-config.json' { + $fonts = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + New-Item -ItemType Directory -Path $fonts -Force | Out-Null + $font = Join-Path $fonts 'JetBrainsMonoNerdFont-Regular.ttf' + [System.IO.File]::WriteAllText($font, 'x', $utf8) + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path (Get-PspCacheDir) 'terminal-config.json'), '{ "fontInstall": { "displayName": "JetBrainsMono NF" } }', $utf8) + $script:PSP.TestFontScopes = @(@{ Name = 'per-user'; Dir = $fonts; Reg = 'HKCU:\SOFTWARE\PowerShellPerfectTest\Fonts-NotPresent'; NeedsAdmin = $false }) + Uninstall-Profile -RemoveFonts -Confirm:$false 6>$null 3>$null | Out-Null + Assert-True (-not (Test-Path -LiteralPath $font)) 'the font named in terminal-config.json was not removed' +} + +Test-Case 'Uninstall-Profile -All stays inside the sandbox font folder' { + $fonts = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + New-Item -ItemType Directory -Path $fonts -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $fonts 'CaskaydiaCoveNerdFont-Regular.ttf'), 'x', $utf8) + [System.IO.File]::WriteAllText((Join-Path $fonts 'CascadiaCode.ttf'), 'x', $utf8) + $script:PSP.TestFontScopes = @(@{ Name = 'per-user'; Dir = $fonts; Reg = 'HKCU:\SOFTWARE\PowerShellPerfectTest\Fonts-NotPresent'; NeedsAdmin = $false }) + Uninstall-Profile -All -Confirm:$false 6>$null 3>$null | Out-Null + Assert-True (-not (Test-Path -LiteralPath (Join-Path $fonts 'CaskaydiaCoveNerdFont-Regular.ttf'))) 'Nerd Font file not removed' + Assert-True (Test-Path -LiteralPath (Join-Path $fonts 'CascadiaCode.ttf')) 'unrelated font removed' +} + +Write-Host '-- Command guards and data handling (F-10, F-19, F-22, F-23, F-24, F-25) --' -ForegroundColor Magenta + +Test-Case 'killport refuses protected system processes' { + $script:Stopped = @() + function Get-NetTCPConnection { param([int]$LocalPort, $ErrorAction) [PSCustomObject]@{ OwningProcess = 4242 } } + function Get-Process { param([int]$Id, $ErrorAction) [PSCustomObject]@{ Id = 4242; ProcessName = 'svchost' } } + function Stop-Process { param([int]$Id, [switch]$Force, $ErrorAction) $script:Stopped += $Id } + killport 135 -Confirm:$false 6>$null 3>$null + Assert-True ($script:Stopped.Count -eq 0) 'protected process was stopped' +} + +Test-Case 'pkill refuses match-everything patterns' { + $err = $null + pkill '*' -ErrorAction SilentlyContinue -ErrorVariable err + Assert-True ((@($err) -join ' ') -match 'refusing') 'match-all pattern accepted' +} + +Test-Case 'Clear-Cache refuses a TEMP that is not a temp folder' { + $docs = Join-Path $script:Sandbox.Root 'Documents' + $keep = Join-Path $docs 'keep.txt' + [System.IO.File]::WriteAllText($keep, 'x', $utf8) + $env:TEMP = $docs; $env:TMP = $docs + Clear-Cache -Confirm:$false 6>$null 3>$null + Assert-True (Test-Path -LiteralPath $keep) 'non-temp folder was emptied' + $env:TEMP = $script:Sandbox.Temp; $env:TMP = $script:Sandbox.Temp + $junk = Join-Path $script:Sandbox.Temp 'junk.tmp' + [System.IO.File]::WriteAllText($junk, 'x', $utf8) + Clear-Cache -Confirm:$false 6>$null 3>$null + Assert-True (-not (Test-Path -LiteralPath $junk)) 'temp folder not cleared' +} + +Test-Case 'Clear-Cache accepts a Remote Desktop temp folder and keeps going past a file in use' { + $rdp = Join-Path $script:Sandbox.Temp '2' + $sub = Join-Path $rdp 'sub' + New-Item -ItemType Directory -Path $sub -Force | Out-Null + $locked = Join-Path $sub 'a-locked.txt' + $other = Join-Path $sub 'b-other.txt' + foreach ($f in $locked, $other) { [System.IO.File]::WriteAllText($f, 'x', $utf8) } + $env:TEMP = $rdp; $env:TMP = $rdp + $stream = [System.IO.File]::Open($locked, 'Open', 'Read', 'None') + try { Clear-Cache -Confirm:$false 6>$null 3>$null } + finally { $stream.Dispose(); $env:TEMP = $script:Sandbox.Temp; $env:TMP = $script:Sandbox.Temp } + Assert-True (-not (Test-Path -LiteralPath $other)) 'files next to a file in use were not removed' +} + +Test-Case 'trash refuses when Windows would delete permanently instead of recycling' { + $file = Join-Path $script:Sandbox.Work 'keep.txt' + [System.IO.File]::WriteAllText($file, 'x', $utf8) + function Test-PspRecycleBinSession { $false } + $err = $null + # -WhatIf keeps the file safe even if the guard regresses. + trash $file -WhatIf -ErrorAction SilentlyContinue -ErrorVariable err 6>$null 3>$null + Assert-True ((@($err) -join ' ') -match 'interactive desktop session') 'no refusal outside an interactive session' + Assert-True (Test-Path -LiteralPath $file) 'file removed' +} + +Test-Case 'extract accepts an archive on a PowerShell drive' { + $src = Join-Path $script:Sandbox.Work 'p.txt' + [System.IO.File]::WriteAllText($src, 'zip', $utf8) + Compress-Archive -LiteralPath $src -DestinationPath (Join-Path $script:Sandbox.Work 'p.zip') + $dest = Join-Path $script:Sandbox.Work 'pdest' + New-Item -ItemType Directory -Path $dest -Force | Out-Null + New-PSDrive -Name PspUnit -PSProvider FileSystem -Root $script:Sandbox.Work | Out-Null + Push-Location $dest + try { + extract 'PspUnit:\p.zip' 6>$null + Assert-True (Test-Path -LiteralPath (Join-Path $dest 'p.txt')) 'archive on a PSDrive not extracted' + } + finally { Pop-Location; Remove-PSDrive -Name PspUnit -ErrorAction SilentlyContinue } +} + +Test-Case 'No variable name in the profile ends in ? (a "$Target?" string names the variable Target?)' { + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $bad = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] -and $n.VariablePath.UserPath.Length -gt 1 -and $n.VariablePath.UserPath.EndsWith('?') }, $true)) + Assert-True ($bad.Count -eq 0) "line(s) $(@($bad | ForEach-Object { $_.Extent.StartLineNumber }) -join ', ')" +} + +Test-Case 'The Oh My Posh prompt falls back without running the prompt hooks twice' { + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $assign = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and $n.Left.Extent.Text -eq '$Function:prompt' -and $n.Right.Extent.Text -match 'Get-OhMyPoshPromptText' }, $true) | Select-Object -First 1 + $ompPrompt = & ([scriptblock]::Create($assign.Right.Extent.Text)) + $script:StageRuns = 0 + function Invoke-PromptStage { $script:StageRuns++ } + function Get-OhMyPoshPromptText { $null } + $text = & $ompPrompt + Assert-True ($script:StageRuns -eq 1) "prompt hooks ran $($script:StageRuns) times" + Assert-True ([string]$text -match '\] [\$#] $') "fallback prompt was '$text'" +} + +Test-Case 'An Oh My Posh render that times out is stopped with its child processes' { + $saved = $script:PSP.PromptTimeoutMs + # Long enough for the child PowerShell to start ping, which then outlives it unless the whole tree is stopped. + $script:PSP.PromptTimeoutMs = 4000 + $marker = '-n 41 -w 997 127.0.0.1' + try { + $shell = (Get-Process -Id $PID).Path + $result = Invoke-OhMyPoshCommand -ExecutablePath $shell -Arguments @('-NoProfile', '-NonInteractive', '-Command', "ping.exe $marker") + Assert-True ($null -eq $result) 'a timed-out render returned output' + Start-Sleep -Milliseconds 500 + $left = @(Get-CimInstance Win32_Process -Filter "Name = 'PING.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like "*$marker*" }) + foreach ($p in $left) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } + Assert-True ($left.Count -eq 0) 'the child process of a timed-out render kept running' + } + finally { $script:PSP.PromptTimeoutMs = $saved } +} + +Test-Case 'sed keeps the file encoding and writes a .bak (F-19)' { + $ansi = Join-Path $script:Sandbox.Work 'ansi.txt' + $code = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage + $enc = [System.Text.Encoding]::GetEncoding($code) + $word = 'caf' + [char]0xE9 + if ($enc.GetString($enc.GetBytes($word)) -eq $word) { + [System.IO.File]::WriteAllBytes($ansi, $enc.GetBytes("$word old")) + sed $ansi 'old' 'new' + Assert-True ($enc.GetString([System.IO.File]::ReadAllBytes($ansi)) -eq "$word new") 'ANSI text damaged' + } + $utf16 = Join-Path $script:Sandbox.Work 'utf16.txt' + [System.IO.File]::WriteAllText($utf16, "$word old", [System.Text.UnicodeEncoding]::new($false, $true)) + sed $utf16 'old' 'new' + $bytes = [System.IO.File]::ReadAllBytes($utf16) + Assert-True ($bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE -and [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2) -eq "$word new") 'UTF-16 file converted' + Assert-True (Test-Path -LiteralPath "$utf16.bak") 'no .bak written' +} + +Test-Case 'env masks secret-looking values unless -ShowValues (F-23)' { + $fake = 'gh' + 'p_' + ('A' * 36) + $env:PSP_UNIT_TOKEN = $fake + try { + $masked = env 'PSP_UNIT_TOKEN' | Out-String + $shown = env 'PSP_UNIT_TOKEN' -ShowValues | Out-String + Assert-True ($masked -notmatch [regex]::Escape($fake) -and $masked -match '\*{8}') 'value not masked' + Assert-True ($shown -match [regex]::Escape($fake)) '-ShowValues did not reveal the value' + } + finally { Remove-Item env:PSP_UNIT_TOKEN -ErrorAction SilentlyContinue } + foreach ($name in 'DB_PASS', 'MYSQL_PWD', 'AZURE_STORAGE_ACCOUNT_KEY', 'SQL_CONNECTION_STRING', 'GPG_PASSPHRASE') { + Assert-True (Test-PspSensitiveVariable -Name $name -Value 'x') "$name not treated as secret" + } + $key = 'A' * 40 + foreach ($value in "Server=db;User Id=app;Password=$key", "DefaultEndpointsProtocol=https;AccountName=a;AccountKey=$key", "https://a.blob.core.windows.net/c?sv=2022&sig=$key") { + Assert-True (Test-PspSensitiveVariable -Name 'PSP_UNIT_VALUE' -Value $value) "value not treated as secret: $($value.Substring(0, 12))..." + } + foreach ($name in 'PATH', 'PASSAGE_DIR', 'COMPASS_HOME') { Assert-True (-not (Test-PspSensitiveVariable -Name $name -Value 'C:\x')) "$name masked" } +} + +Test-Case 'Format-PspTlsErrors names host and chain problems (F-22)' { + $both = [System.Net.Security.SslPolicyErrors]::RemoteCertificateNameMismatch -bor [System.Net.Security.SslPolicyErrors]::RemoteCertificateChainErrors + $text = Format-PspTlsErrors -Errors $both -Domain 'example.com' + Assert-True ($text -match 'name does not match example.com' -and $text -match 'chain not trusted') "unexpected: $text" + Assert-True ((Format-PspTlsErrors -Errors ([System.Net.Security.SslPolicyErrors]::None) -Domain 'x') -eq '') 'clean certificate reported problems' +} + +Test-Case 'extract refuses to overwrite existing files without -Force (F-24)' { + $src = Join-Path $script:Sandbox.Work 'a.txt' + [System.IO.File]::WriteAllText($src, 'from-zip', $utf8) + $zip = Join-Path $script:Sandbox.Work 'a.zip' + Compress-Archive -LiteralPath $src -DestinationPath $zip + $dest = Join-Path $script:Sandbox.Work 'dest' + New-Item -ItemType Directory -Path $dest -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $dest 'a.txt'), 'mine', $utf8) + Push-Location $dest + try { + $err = $null + extract $zip -ErrorAction SilentlyContinue -ErrorVariable err 6>$null + Assert-True ((@($err) -join ' ') -match 'already exist' -and [System.IO.File]::ReadAllText((Join-Path $dest 'a.txt')) -eq 'mine') 'existing file overwritten' + extract $zip -Force 6>$null + Assert-True ([System.IO.File]::ReadAllText((Join-Path $dest 'a.txt')) -eq 'from-zip') '-Force did not overwrite' + } + finally { Pop-Location } +} + +Test-Case 'Copy-SshKey remote script appends safely and skips a key that is present (F-24)' { + $sh = @((Join-Path $env:ProgramFiles 'Git\bin\sh.exe'), (Join-Path $env:ProgramFiles 'Git\usr\bin\sh.exe')) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $sh) { Write-Host ' (Git sh.exe not found; skipped)' -ForegroundColor DarkGray; return } + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $fn = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Copy-SshKey' }, $true) | Select-Object -First 1 + $assign = $fn.Body.FindAll({ param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and $n.Left.Extent.Text -eq '$remoteScript' }, $true) | Select-Object -First 1 + $remoteScript = $assign.Right.Expression.Value + $sshHome = Join-Path $script:Sandbox.Work 'home' + New-Item -ItemType Directory -Path (Join-Path $sshHome '.ssh') -Force | Out-Null + $auth = Join-Path $sshHome '.ssh\authorized_keys' + [System.IO.File]::WriteAllText($auth, 'ssh-ed25519 AAAAexisting old@host', $utf8) + $savedHome = $env:HOME + $env:HOME = $sshHome + try { + $null = 'ssh-ed25519 AAAAnewkey me@pc' | & $sh -c $remoteScript + $lines = @([System.IO.File]::ReadAllLines($auth) | Where-Object { $_ }) + Assert-True ($lines.Count -eq 2 -and $lines[0] -eq 'ssh-ed25519 AAAAexisting old@host' -and $lines[1] -eq 'ssh-ed25519 AAAAnewkey me@pc') "unexpected authorized_keys: $($lines -join ' | ')" + $again = @('ssh-ed25519 AAAAnewkey me@pc' | & $sh -c $remoteScript) + Assert-True ($again -contains 'PSP_KEY_ALREADY_PRESENT') 'present key not detected' + Assert-True (@([System.IO.File]::ReadAllLines($auth) | Where-Object { $_ }).Count -eq 2) 'key appended twice' + } + finally { $env:HOME = $savedHome } +} + +Test-Case 'NO_COLOR drops colors from Write-PspHost and Show-Help (F-25)' { + $script:HostCalls = @() + function Write-Host { param([object]$Object, [System.ConsoleColor]$ForegroundColor, [switch]$NoNewline, $BackgroundColor, $Separator) $script:HostCalls += , @($PSBoundParameters.Keys) } + $env:NO_COLOR = '1' + Write-PspHost 'x' -ForegroundColor Red + Assert-True (-not ($script:HostCalls[0] -contains 'ForegroundColor')) 'color passed despite NO_COLOR' + $env:NO_COLOR = $null + Write-PspHost 'x' -ForegroundColor Red + Assert-True ($script:HostCalls[1] -contains 'ForegroundColor') 'color dropped without NO_COLOR' + Remove-Item function:Write-Host + $env:NO_COLOR = '1' + $help = Show-Help 6>&1 | Out-String + Assert-True ($help -notmatch [char]27 -and $help -match 'version \d+\.\d+\.\d+') 'Show-Help printed escape codes or no version' +} + +$script:Stopwatch.Stop() +Write-Host '' +$color = if ($script:Failed -gt 0) { 'Red' } else { 'Green' } +Write-Host (" {0} passed, {1} failed in {2:N1}s" -f $script:Passed, $script:Failed, $script:Stopwatch.Elapsed.TotalSeconds) -ForegroundColor $color +exit ([int]($script:Failed -gt 0)) diff --git a/theme.json b/theme.json index 418d312..8b9b15d 100644 --- a/theme.json +++ b/theme.json @@ -1,7 +1,8 @@ { "theme": { "name": "pure", - "url": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/pure.omp.json" + "url": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/v31.8.0/themes/pure.omp.json", + "sha256": "E0786DA374967BF4B4D3B107580AC86246C132679C309291F378D73C1E804E5A" }, "windowsTerminal": { "colorScheme": "Tokyo Night",