From 165d2f4a101acfaa2df68fc17b9af51261560900 Mon Sep 17 00:00:00 2001 From: 26zl <143036376+26zl@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:58:39 +0100 Subject: [PATCH 1/2] Port the startup work onto main and fix the November production audit Startup and Oh My Posh - Faster interactive start: lazy PSFzf and completers, cached continuation prompt, prefetched first prompt, native JSON first, a no-GC region until the first prompt, and the portable Oh My Posh build (pinned, SHA-256 and signer checked) instead of the slower MSIX build. - Oh My Posh older than 29.35.1 (CVE-2026-73505) never renders the prompt; the managed portable build takes over, psp-doctor reports FAIL, and setup and Update-Tools install the portable build next to it. - Runs of a script or command (-File/-Command without -NoExit) count as non-interactive, so they skip prompt setup. Security - The theme guard scans the whole theme for command segments, template functions that run programs and extends (opt-in: theme.allowCommands). - Quoting doubles typographic quotes too; lint always passes its own settings; envload and the history filter cover more names and key formats; wslpath runs without the distro shell; serve skips npm install scripts; wt -d never gets a folder with ';'; local release files are checked against the SHA256SUMS.txt next to them; release.yml requires the tagged commit to be on main; genpass keeps passwords out of clipboard history. Fixes - Uninstall-Profile never replaces or deletes a profile that is not this one. - Keybinding ids keep punctuation keys apart, and a binding the profile no longer sets is retired on the next Update-Profile. - Update-Tools keeps a newer Oh My Posh, uses --exact and reports tools winget did not install as preserved; PATH refresh appends instead of replacing; zoxide init generates its cache once; Update-Profile parses a download before installing it and records the commit when up to date; setup falls back to the default font when the wizard font fails; file, entropy, vtscan and defscan accept PowerShell drive paths. Repository - CHANGELOG.md, CODE_OF_CONDUCT.md and .mailmap are removed and AGENTS.md is no longer tracked; release notes come from merged pull requests (.github/release.yml). - Docs, issue templates, attribution for Open-Meteo and tldr, and test hygiene; 17 new regression tests (tests/unit.ps1 now has 103). --- .github/ISSUE_TEMPLATE/bug_report.yml | 11 +- .github/ISSUE_TEMPLATE/tool_issue.yml | 13 +- .github/PULL_REQUEST_TEMPLATE.md | 1 - .github/release.yml | 17 + .github/workflows/ci.yml | 11 +- .github/workflows/release.yml | 26 +- .github/workflows/scheduled.yml | 8 + .gitignore | 3 + .mailmap | 1 - AGENTS.md | 36 - CHANGELOG.md | 66 - CODE_OF_CONDUCT.md | 19 - CONTRIBUTING.md | 32 +- Microsoft.PowerShell_profile.ps1 | 1714 +++++++++++++++++++------ PSScriptAnalyzerSettings.psd1 | 2 +- README.md | 91 +- SECURITY.md | 29 +- setprofile.ps1 | 10 +- setup.ps1 | 157 ++- terminal-config.json | 7 +- tests/ci-functional.ps1 | 22 +- tests/lib/Sandbox.ps1 | 2 +- tests/locallab.ps1 | 21 +- tests/rawhunt.ps1 | 113 +- tests/test.ps1 | 138 +- tests/unit.ps1 | 671 +++++++++- 26 files changed, 2348 insertions(+), 873 deletions(-) create mode 100644 .github/release.yml delete mode 100644 .mailmap delete mode 100644 AGENTS.md delete mode 100644 CHANGELOG.md delete mode 100644 CODE_OF_CONDUCT.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c6253f1..11fc9dd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,7 +8,7 @@ body: attributes: label: Pre-flight checklist options: - - label: I have run `Update-Profile` and the issue persists + - label: I have checked the [troubleshooting table](https://github.com/26zl/PowerShellPerfect#troubleshooting) - label: I have run `Clear-ProfileCache` and the issue persists - label: I have searched existing issues for duplicates required: true @@ -33,6 +33,15 @@ body: validations: required: true + - type: textarea + id: psp-doctor + attributes: + label: psp-doctor output + description: 'Run `psp-doctor` and paste the output: it shows the profile version, the commit it was installed from, and the state of tools, caches and fonts. Paths include your user name; edit it out if you like. If the profile does not load, say so.' + render: text + validations: + required: true + - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/tool_issue.yml b/.github/ISSUE_TEMPLATE/tool_issue.yml index 23dec4d..bbfc180 100644 --- a/.github/ISSUE_TEMPLATE/tool_issue.yml +++ b/.github/ISSUE_TEMPLATE/tool_issue.yml @@ -1,7 +1,7 @@ name: Tool Issue description: Report an issue with a managed tool (eza, zoxide, fzf, bat, ripgrep, Oh My Posh) title: "[Tool] " -labels: ["tool-issue"] +labels: ["bug"] body: - type: dropdown id: tool @@ -21,7 +21,7 @@ body: id: tool-version attributes: label: Tool version - description: "Run `Update-Tools` or check the tool's `--version` output" + description: "Run the tool with `--version` (for Oh My Posh, `oh-my-posh version`)" placeholder: "e.g. eza 0.20.14" validations: required: true @@ -37,6 +37,15 @@ body: validations: required: true + - type: textarea + id: psp-doctor + attributes: + label: psp-doctor output + description: 'Run `psp-doctor` and paste the output: it shows the profile version, the commit it was installed from, and where each tool was found. Paths include your user name; edit it out if you like.' + render: text + validations: + required: true + - type: textarea id: description attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c3f480f..8ffdaf0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,7 +18,6 @@ - [ ] 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 diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..279f41a --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,17 @@ +# Groups the notes GitHub generates from merged pull requests (gh release create --generate-notes in workflows/release.yml). +changelog: + exclude: + labels: + - dependencies + authors: + - "dependabot[bot]" + categories: + - title: Features + labels: + - enhancement + - title: Fixes + labels: + - bug + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa7b0be..09c4f43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,7 @@ jobs: - name: Run PSScriptAnalyzer (PSScriptAnalyzerSettings.psd1) shell: pwsh - # 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. + # tests/ is excluded: the harnesses call commands by alias and hold fixtures that trip the analyzer rules. run: pwsh -NoProfile -File tests/lint.ps1 -NoInstall - name: Smoke-test profile (PS7, sandboxed, load-time budget) @@ -80,8 +79,7 @@ jobs: '/home/' '\\\\Users\\\\' ) - # tests/test.ps1 replicates this same check locally, so it contains the regex patterns - # we are searching for; excluded by name to avoid self-matching. + # tests/test.ps1 contains these patterns, so it is excluded by name. $files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne 'test.ps1' } $found = @() foreach ($file in $files) { @@ -314,7 +312,10 @@ jobs: # 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' } + # A missing line (index -1) fails as well. + $verifyAt = $setup.IndexOf('$script:Bundle = Initialize-InstallBundle', [System.StringComparison]::Ordinal) + $installAt = $setup.IndexOf('$installed = Invoke-SetupInstall', [System.StringComparison]::Ordinal) + 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 "FAIL: $_" -ForegroundColor Red }; exit 1 } Write-Host 'setup.ps1 structure OK' -ForegroundColor Green diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a04ed55..ac4e0c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,18 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # Full history, so the next step can see whether the tagged commit is on main. + fetch-depth: 0 + + - name: Check that the tagged commit is on main + shell: bash + run: | + set -euo pipefail + # A tag on a commit that never reached main skipped review and CI, so it is not released. + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "::error::${GITHUB_SHA} is not on main. Tag a commit that is on main." + exit 1 + fi - name: Build release files from the tagged commit shell: bash @@ -47,17 +59,11 @@ jobs: (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 + # Release notes: how to verify and install, followed by the notes GitHub generates from the merged pull requests. { - 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 "Each file's SHA-256 is in \`SHA256SUMS.txt\`; \`gh attestation verify --repo ${GITHUB_REPOSITORY} --source-ref refs/tags/${TAG} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/release.yml\` checks that it was built by this workflow 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 @@ -77,6 +83,6 @@ jobs: 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 + # Upload the files to a draft first, so the release is complete when it is published (required for immutable releases). + gh release create "${TAG}" dist/* --repo "${GITHUB_REPOSITORY}" --draft --verify-tag --title "PowerShellPerfect ${TAG}" --notes-file notes.md --generate-notes gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index cc4628e..10c0357 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -85,6 +85,14 @@ jobs: 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" } + # The portable oh-my-posh build pinned in the profile against that release's checksums.txt. + $ompSums = Join-Path $work 'omp-checksums.txt' + Invoke-WebRequest "https://github.com/JanDeDobbeleer/oh-my-posh/releases/download/v$($pins.OhMyPosh)/checksums.txt" -OutFile $ompSums + $omp = @{} + foreach ($line in Get-Content $ompSums) { if ($line -match '^([0-9a-fA-F]{64})\s+\*?(\S+)$') { $omp[$Matches[2]] = $Matches[1].ToUpperInvariant() } } + foreach ($asset in $pins.OhMyPoshSha256.Keys) { + if ($omp[$asset] -ne $pins.OhMyPoshSha256[$asset].ToUpperInvariant()) { $problems += "profile hash for $asset does not match oh-my-posh v$($pins.OhMyPosh)" } + } # Newer upstream releases are reported, not failed. foreach ($repo in 'ryanoasis/nerd-fonts', 'JanDeDobbeleer/oh-my-posh') { diff --git a/.gitignore b/.gitignore index 809d5be..95a93cc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,10 @@ Modules/ .vscode/ *.code-workspace .idea/ + +# Local AI agent instructions (kept out of the repository) CLAUDE.md +AGENTS.md .claude/ # OS junk diff --git a/.mailmap b/.mailmap deleted file mode 100644 index 19b5b1b..0000000 --- a/.mailmap +++ /dev/null @@ -1 +0,0 @@ -26zl <143036376+26zl@users.noreply.github.com> diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 26cf4d5..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index 3d71494..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 deleted file mode 100644 index 4ec5bca..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,19 +0,0 @@ -# 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 5fca073..e43b48a 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, tests, and the PR and release process. Please also read the [Code of Conduct](CODE_OF_CONDUCT.md). +Thanks for your interest in contributing! This guide covers the dev setup, the architecture, coding conventions, tests, and the PR and release process. ## Dev Environment Setup @@ -21,6 +21,16 @@ Thanks for your interest in contributing! This guide covers the dev setup, codin 3. Restart your terminal to load the profile. +## Architecture and Invariants + +- **One profile file.** `Microsoft.PowerShell_profile.ps1` holds every command, hook and completer; `setup.ps1` installs it and runs the wizard. A measured split into a core file and lazily loaded commands saved only about 22 ms at startup, added about 48 ms to the first use of each moved command, and put a second file on every install, update and uninstall path. +- **Load-time state.** Temporaries created while the profile loads are named `$_psp*` and removed at the end of the load, so code that runs later keeps its state in `$script:PSP` or calls a helper such as `Get-PspCacheDir`. +- **One helper per job.** Output goes through `Write-PspHost` (honors `NO_COLOR`), JSON through `Read-JsonFile` / `ConvertFrom-JsonC`, and files through `Get-Utf8FileText` / `Write-Utf8FileAtomic`. +- **Windows Terminal.** `settings.json` is changed only through `Update-WindowsTerminalSettingsFile`, which records each change and the value it replaced in `wt-changes.json`. `Undo-WindowsTerminalSettingsFile` reverts from that record, and `Uninstall-Profile` relies on it, so a change written any other way is not reverted. +- **Pinned downloads.** The profile's runtime downloads (PSFzf, `http-server` for `serve`, the portable Oh My Posh build) are pinned in `Get-PspComponentPins`; setup's Nerd Fonts and preset themes are pinned in `setup.ps1`, and the default theme and font in `theme.json` and `terminal-config.json`. The `pins` job in `.github/workflows/scheduled.yml` checks them against upstream every week. +- **setup.ps1 runs the profile's code.** After verifying the bundle it loads the helpers named in `$script:SharedFunctionNames` from the verified profile. A helper added after 1.0.0 goes in `$script:OptionalSharedFunctionNames`, which is loaded only when the profile defines it, so `setup.ps1 -Ref v1.0.0` still installs that release. See [Code Shared With setup.ps1](#code-shared-with-setupps1) for the helpers needed before verification. +- **Startup time.** The load path runs every time a terminal opens. Keep process spawns, module imports, `Get-Command` lookups that can miss (a missing tool costs a scan of every `PATH` folder; use `Find-ProfileExecutable`) and repeated JSON parsing off it: defer them to first use or cache the result. CI runs `tests/smoke.ps1` on both editions and fails when the median load time is over its budget. + ## Coding Conventions ### Dual PS5/PS7 Compatibility @@ -54,7 +64,7 @@ All code must work under both PowerShell 5.1 and 7+. Key differences: ### 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). + Also set `UpgradeStrategy`: `winget` for normal tools; `portable` is used by Oh My Posh, whose profile-managed portable build (`Install-OhMyPoshPortable`, pinned in `Get-PspComponentPins`) `Update-Tools` keeps current, replacing an MSIX install and leaving other direct installs alone. 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` @@ -73,15 +83,15 @@ All code must work under both PowerShell 5.1 and 7+. Key differences: ### 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`. +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. Mark the `param()` entries it does not use with `$null = $commandName, $parameterName, $commandAst, $fakeBoundParameters`. `PSReviewUnusedParameter` is excluded in `PSScriptAnalyzerSettings.psd1`; the line is kept so unused completer parameters are visibly intentional. ## Tests -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). +Run the suites from a **normal (non-elevated)** shell. The ones that load the profile redirect `LOCALAPPDATA`, `TEMP` and `$PROFILE` into disposable sandboxes and block commands that would change the machine. `tests/unit.ps1`, `tests/test.ps1`, `tests/ci-functional.ps1`, `tests/rawhunt.ps1` and the test steps of `tests/locallab.ps1` refuse to run elevated unless you pass `-AllowElevated` (meant for CI runners and disposable VMs); `tests/lint.ps1` and `tests/smoke.ps1` do not check. ```powershell 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/lint.ps1 # PSScriptAnalyzer 1.25.0 with PSScriptAnalyzerSettings.psd1 (installed for the current user when missing; -NoInstall fails instead) 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) @@ -89,6 +99,8 @@ pwsh -NoProfile -File tests/ci-functional.ps1 # what the CI functional job run `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. +Tests must never reach the real machine: load the profile inside a sandbox and dot-source `$PspPostLoadStubs`, which points `Uninstall-Profile -RemoveFonts` at a sandbox font store, and never create `telemetry.owned` in a sandbox before `Uninstall-Profile` runs, because uninstall then removes your real `POWERSHELL_TELEMETRY_OPTOUT` variable. + **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. ### Manual Checks Before a Release @@ -101,9 +113,8 @@ CI runs on Windows Server images with an English display language. Before taggin 2. Make your changes following the conventions above 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. 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 +5. Open a PR - the template will guide you through the checklist; describe user-visible changes in the PR, since the release notes are generated from merged pull requests. `.github/release.yml` lists PRs labeled `enhancement` under Features, `bug` under Fixes and the rest under Other changes, and leaves out Dependabot PRs +6. All three CI jobs must pass before merge; the repository ruleset enforces `lint`, `install-flow` and `functional` as required checks ## CI Checks @@ -118,6 +129,5 @@ CI runs on push/PR to `main` with three jobs (on a pinned `windows-2025` image): ## Releases 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. +2. 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 writes release notes with the verification steps followed by GitHub's notes generated from the merged pull requests. +3. 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 8e7818a..754ed12 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -8,13 +8,32 @@ if (-not [bool]$env:AI_AGENT -and ([bool]$env:AGENT_ID -or [bool]$env:CLAUDE_COD $env:AI_AGENT = '1' } -# Detect non-interactive environments that should skip network calls and UI setup. +# A run of a script or command (-File, -Command, -EncodedCommand) never shows a prompt unless -NoExit comes before it. +# Only the documented spellings are listed (case-insensitive -in, no regex: this runs on every start); others count as interactive. +$_pspScriptRun = $false +$_pspArgs = [Environment]::GetCommandLineArgs() +foreach ($_pspArg in $_pspArgs) { + if ($_pspArg -in '-noe', '-noexit', '--noexit', '/noexit') { break } + if ($_pspArg -in '-c', '-command', '--command', '/c', '/command', '-f', '-file', '--file', '/f', '/file', '-e', '-ec', '-enc', '-encodedcommand', '/e', '/ec', '/encodedcommand') { + $_pspScriptRun = $true + break + } +} + +# Detect non-interactive environments that should skip network calls and UI setup; CI=false or CI=0 does not count as CI. $isInteractive = [Environment]::UserInteractive -and --not [bool]$env:CI -and +-not ([bool]$env:CI -and $env:CI -ne 'false' -and $env:CI -ne '0') -and -not [bool]$env:AI_AGENT -and -not ($host.Name -eq 'Default Host') -and -not $(try { [Console]::IsOutputRedirected } catch { $false }) -and --not ([Environment]::GetCommandLineArgs() | Where-Object { $_ -match '(?i)^-NonI' }) +-not (@($_pspArgs) -like '-NonI*') -and +-not $_pspScriptRun + +# Reserve 128 MB so no GC pauses the load or first prompt; Exit-PspNoGcRegion ends the reservation. +$script:PspNoGcRegion = $false +if ($isInteractive) { + try { $script:PspNoGcRegion = [System.GC]::TryStartNoGCRegion(128MB) } catch { $script:PspNoGcRegion = $false } +} # Enforce TLS 1.2 for network commands on Windows PowerShell 5.1. if ($PSVersionTable.PSVersion.Major -lt 6) { @@ -24,12 +43,12 @@ if ($PSVersionTable.PSVersion.Major -lt 6) { # 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' + [System.IO.Path]::Combine($base, 'PowerShellProfile') } -# Kept for scripts that read it; profile functions call Get-PspCacheDir instead. +# Load-time code reads $cacheDir; functions call Get-PspCacheDir. $cacheDir = Get-PspCacheDir -if (-not (Test-Path -LiteralPath $cacheDir)) { +if (-not [System.IO.Directory]::Exists($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)" } @@ -48,7 +67,7 @@ $script:ProtectedProcessNames = @('System', 'Idle', 'Registry', 'csrss', 'winini # Define tool installation, upgrade, cache, and version metadata. $script:ProfileTools = @( - @{ Name = "Oh My Posh"; Id = "JanDeDobbeleer.OhMyPosh"; Cmd = "oh-my-posh"; Cache = $null; VerCmd = "version"; UpgradeStrategy = "preserve-direct" } + @{ Name = "Oh My Posh"; Id = "JanDeDobbeleer.OhMyPosh"; Cmd = "oh-my-posh"; Cache = $null; VerCmd = "version"; UpgradeStrategy = "portable" } @{ Name = "eza"; Id = "eza-community.eza"; Cmd = "eza"; Cache = $null; VerCmd = "--version"; UpgradeStrategy = "winget" } @{ Name = "zoxide"; Id = "ajeetdsouza.zoxide"; Cmd = "zoxide"; Cache = "zoxide-init.ps1"; VerCmd = "--version"; UpgradeStrategy = "winget" } @{ Name = "fzf"; Id = "junegunn.fzf"; Cmd = "fzf"; Cache = $null; VerCmd = "--version"; UpgradeStrategy = "winget" } @@ -85,6 +104,8 @@ $script:PSP = @{ commandOverrides = $false # Keep the weekly GitHub update check disabled by default. updateCheck = $false + # fastGitPrompt renders oh-my-posh from a tuned theme copy without per-prompt git calls for upstream icons and stash counts. + fastGitPrompt = $true } # Return the collapsed prompt text used by the customizable transient prompt. TransientPrompt = { "$ " } @@ -196,6 +217,8 @@ function Invoke-DownloadWithRetry { [int]$MaxAttempts = 2, [int]$BackoffSec = 2 ) + # The progress display slows large downloads on Windows PowerShell 5.1 a lot; this scope only. + $ProgressPreference = 'SilentlyContinue' for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue @@ -220,7 +243,7 @@ function Invoke-DownloadWithRetry { # 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) + param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$ArgumentList) ($ArgumentList | ForEach-Object { if ($_ -eq '') { '""' } elseif ($_ -notmatch '[\s"]') { $_ } @@ -293,14 +316,23 @@ function Write-Utf8FileAtomic { # 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 ([string]::IsNullOrWhiteSpace($Text)) { return $null } + # Text that parses as it is skips the comment scan, which keeps it off the startup path; PowerShell 7 parses comments and + # trailing commas itself, and on Windows PowerShell such text fails here and is cleaned below. + $parsed = $null + $needsCleaning = $false + try { $parsed = $Text | ConvertFrom-Json -ErrorAction Stop } + catch { $needsCleaning = $true } + if ($needsCleaning) { + $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 } @@ -484,8 +516,16 @@ function Get-LatestMainCommitSha { # 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' + PSFzf = '2.7.12' + HttpServer = '14.1.1' + # The portable oh-my-posh build: release version and each Windows asset's SHA-256 from that release's checksums.txt. + OhMyPosh = '31.9.1' + OhMyPoshSha256 = @{ + 'posh-windows-amd64.exe' = 'C2B34D2E8D9DB243172170CE4ADB70B0BD1476D29D355BC783CFA886724EB27E' + 'posh-windows-arm64.exe' = 'D9694FFA0EF7C24A6518C29AB66E995DF7C1DBC24031229888C742ED83486A67' + } + # The oldest oh-my-posh the prompt renders with: earlier builds run template code taken from folder names (CVE-2026-73505). + OhMyPoshMinimum = '29.35.1' } } @@ -564,10 +604,48 @@ function Get-ExternalCommandPath { return $null } +# Resolve an executable through direct PATH file probes, which stays fast when the tool is missing (startup checks use this). +function Find-ProfileExecutable { + param( + [Parameter(Mandatory)] + [string]$Name + ) + + # Get-Command for a missing tool scans every PATH folder for every PATHEXT extension; one File.Exists per folder is much cheaper. + $onWindows = [System.IO.Path]::DirectorySeparatorChar -eq '\' + $suffix = [string][System.IO.Path]::DirectorySeparatorChar + $(if ($onWindows -and -not [System.IO.Path]::HasExtension($Name)) { $Name + '.exe' } else { $Name }) + foreach ($dir in ([string]$env:PATH).Split([System.IO.Path]::PathSeparator)) { + if ($dir -and [System.IO.File]::Exists($dir + $suffix)) { return [System.IO.Path]::GetFullPath($dir + $suffix) } + } + return $null +} + +# Check PSModulePath folders for a module directory without the cost of Get-Module -ListAvailable. +function Test-ProfileModuleInstalled { + param( + [Parameter(Mandatory)] + [string]$Name + ) + + foreach ($root in ([string]$env:PSModulePath).Split([System.IO.Path]::PathSeparator)) { + $root = $root.Trim().Trim('"') + if ($root -and [System.IO.Directory]::Exists($root + [System.IO.Path]::DirectorySeparatorChar + $Name)) { return $true } + } + return $false +} + +# Append the registry PATH entries this session lacks (new installs) and keep the session's own entries, such as an active venv. 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 ';' + $sessionEntries = @(([string]$env:PATH) -split ';' | Where-Object { $_ }) + $known = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($entry in $sessionEntries) { $null = $known.Add($entry.TrimEnd('\')) } + $added = [System.Collections.Generic.List[string]]::new() + foreach ($scope in 'Machine', 'User') { + foreach ($entry in @(([string][System.Environment]::GetEnvironmentVariable('PATH', $scope)) -split ';')) { + if (-not [string]::IsNullOrWhiteSpace($entry) -and $known.Add($entry.TrimEnd('\'))) { $added.Add($entry) } + } + } + if ($added.Count -gt 0) { $env:PATH = (@($sessionEntries) + @($added)) -join ';' } } # Manage restorable tab titles for long-running commands. @@ -585,10 +663,6 @@ function Pop-TabTitle { try { $Host.UI.RawUI.WindowTitle = $OldTitle } catch { $null = $_ } } -# Return the first Windows Terminal settings file found across supported install variants (or $null). -function Get-WindowsTerminalSettingsPath { - @(Get-WindowsTerminalSettingsPaths)[0] -} # Return all Windows Terminal settings files found across supported install variants. function Get-WindowsTerminalSettingsPaths { @@ -725,7 +799,8 @@ function Set-WtRecordedKeybinding { $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]', '') + # Punctuation is written as hex so ctrl+= and ctrl+- get different ids; keys made of letters and digits keep their earlier ids. + $id = 'User.profile.' + [regex]::Replace(($keys -replace '\+', ''), '[^a-zA-Z0-9]', { param($m) '{0:x2}' -f [int][char]$m.Value }) $newFormat = Test-PspProp $Root 'keybindings' $record = Get-PspProp $Section $keys if (-not $record) { @@ -754,7 +829,7 @@ function Set-WtRecordedKeybinding { $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 + Set-PspProp $record 'id' $id; $record.format = 'new'; $record.appliedAction = $action; $record.appliedBinding = $bindingEntry } else { $actions = @($actions | Where-Object { (Get-PspProp $_ 'keys') -ne $keys }) @@ -764,6 +839,59 @@ function Set-WtRecordedKeybinding { } } +# Revert keybindings the profile set earlier for keys the configuration no longer lists, keeping any the user changed since. +function Remove-WtRetiredKeybinding { + param( + [Parameter(Mandatory)][object]$Root, + [AllowNull()][AllowEmptyCollection()][object[]]$Keybindings, + [Parameter(Mandatory)][object]$Section, + [AllowNull()][object]$LegacyBaseline + ) + $wantedKeys = @($Keybindings | Where-Object { $null -ne $_ } | ForEach-Object { [string](Get-PspProp $_ 'keys') }) + # The record list is copied first because restored entries are removed from it. + foreach ($p in @($Section.PSObject.Properties)) { + if ($wantedKeys -contains $p.Name) { continue } + if (Restore-WtRecordedKeybinding -Root $Root -Record $p.Value) { Remove-PspProp $Section $p.Name } + } + # Versions without a change record tagged their bindings User.profile.; the pre-install copy shows what each replaced. + if (-not (Test-PspProp $Root 'keybindings')) { return } + $source = if ($null -ne $LegacyBaseline) { $LegacyBaseline } else { [PSCustomObject]@{} } + foreach ($binding in (Get-PspArray $Root 'keybindings')) { + $bindingId = [string](Get-PspProp $binding 'id') + $bindingKeys = [string](Get-PspProp $binding 'keys') + if (-not $bindingId.StartsWith('User.profile.', [System.StringComparison]::Ordinal) -or -not $bindingKeys) { continue } + if ($wantedKeys -contains $bindingKeys -or (Test-PspProp $Section $bindingKeys)) { continue } + $action = (Get-PspArray $Root 'actions') | Where-Object { (Get-PspProp $_ 'id') -eq $bindingId } | Select-Object -First 1 + if ($null -eq $action) { continue } + $removedBindings = @((Get-PspArray $source 'keybindings') | Where-Object { (Get-PspProp $_ 'keys') -eq $bindingKeys }) + $boundIds = @($removedBindings | ForEach-Object { Get-PspProp $_ 'id' }) + $removedActions = @((Get-PspArray $source 'actions') | Where-Object { $boundIds -contains (Get-PspProp $_ 'id') }) + $record = [PSCustomObject]@{ format = 'new'; id = $bindingId; appliedAction = $action; appliedBinding = $binding; removedActions = [object[]]$removedActions; removedBindings = [object[]]$removedBindings } + [void](Restore-WtRecordedKeybinding -Root $Root -Record $record) + } +} + +# Return $true when a Windows Terminal settings file still holds a binding the profile set for keys the configuration no longer lists. +function Test-WtRetiredKeybindingPending { + param([AllowNull()][AllowEmptyCollection()][object[]]$Keybindings, [Parameter(Mandatory)][string]$ManifestPath) + $wantedKeys = @($Keybindings | Where-Object { $null -ne $_ } | ForEach-Object { [string](Get-PspProp $_ 'keys') }) + $files = Get-PspProp (Read-WtChangeManifest -ManifestPath $ManifestPath) 'files' + foreach ($path in @(Get-WindowsTerminalSettingsPaths)) { + $wt = $null + try { $wt = ConvertFrom-JsonC -Text (Get-Utf8FileText $path) } catch { continue } + $actions = Get-PspArray $wt 'actions' + $section = Get-PspProp (Get-PspProp $files $path.ToLowerInvariant()) 'bindings' + foreach ($p in @(Get-WtChangeRecords ([PSCustomObject]@{ bindings = $section }) 'bindings')) { + if ($wantedKeys -notcontains $p.Name -and ($actions | Where-Object { Test-PspJsonEqual $_ $p.Value.appliedAction })) { return $true } + } + foreach ($binding in (Get-PspArray $wt 'keybindings')) { + $bindingKeys = [string](Get-PspProp $binding 'keys') + if (([string](Get-PspProp $binding 'id')).StartsWith('User.profile.', [System.StringComparison]::Ordinal) -and $bindingKeys -and $wantedKeys -notcontains $bindingKeys -and -not (Test-PspProp $section $bindingKeys)) { return $true } + } + } + return $false +} + # 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) @@ -798,9 +926,19 @@ function Set-WtRecordedNoLogo { $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)) { + # PowerShell accepts any unambiguous prefix of -NoLogo (-nol, -nolo) and a slash in place of the dash. + $hasNoLogo = $cmd -match '(?i)(^|\s)[-/]nol' + # The switch goes right after the executable so it never becomes part of a script's or command's arguments, + # and a command line that starts with another program (cmd.exe /k pwsh, wsl.exe) is left alone. + $exeMatch = [regex]::Match($cmd, '^\s*("(?:[^"]*[\\/])?(?:pwsh|powershell)(?:\.exe)?"|(?:[^\s"]*[\\/])?(?:pwsh|powershell)(?:\.exe)?)(?=\s|$)', 'IgnoreCase') + if ($cmd -and -not $hasNoLogo -and $cmd -notmatch '(?i)-(Command|File|EncodedCommand)' -and $exeMatch.Success) { + $newCmd = $exeMatch.Value + ' -NoLogo' + $cmd.Substring($exeMatch.Index + $exeMatch.Length) + } + elseif (-not $cmd -and $src) { + # Generated Preview, x86 and other pwsh variants keep their own executable; only the preferred 'PowerShell' entry uses pwsh.exe from PATH. + $newCmd = if (-not $isPwsh) { 'powershell.exe -NoLogo' } elseif ($profName -eq 'PowerShell') { 'pwsh.exe -NoLogo' } else { $null } + } + elseif ($hasNoLogo -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) { @@ -831,7 +969,8 @@ function Update-WindowsTerminalSettingsFile { [string[]]$RemoveDefaults = @(), # theme.json windowsTerminal section: colorScheme, cursorColor, scheme, theme, themeDefinition. [AllowNull()][object]$WindowsTerminal, - # Keybindings as objects with keys and command. + # Keybindings as objects with keys and command; a binding the profile set earlier that this list no longer holds is + # reverted as on uninstall. Leave the parameter out to keep the profile's keybindings as they are. [AllowNull()][object[]]$Keybindings, # Append -NoLogo to PowerShell profiles in profiles.list. [switch]$NoLogo, @@ -842,14 +981,13 @@ function Update-WindowsTerminalSettingsFile { if ($null -eq $wt) { $wt = [PSCustomObject]@{} } $dir = Split-Path -Parent $Path $leaf = Split-Path -Leaf $Path + # Oldest first by the stamp in the name, since Copy-Item keeps the source LastWriteTime. $timestamped = @(Get-ChildItem -LiteralPath $dir -Filter "$leaf.*.bak" -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '\.\d{8}-\d{6}\.bak$' } | Sort-Object LastWriteTime) + Where-Object { $_.Name -match '\.\d{8}-\d{6}\.bak$' } | Sort-Object Name) $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). + # New values are recorded against the file as read; values it already holds against the pre-install copy (.psp-original, else the oldest backup). $baseline = Copy-PspObject $wt $originalCopy = "$Path.psp-original" $legacyBaseline = $null @@ -875,7 +1013,7 @@ function Update-WindowsTerminalSettingsFile { # 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 | + Where-Object { $_.Name -match '\.\d{8}-\d{6}\.bak$' } | Sort-Object Name -Descending | Select-Object -Skip $KeepBackups | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue -Confirm:$false } $profilesNode = Get-PspProp $wt 'profiles' @@ -943,6 +1081,9 @@ function Update-WindowsTerminalSettingsFile { foreach ($kb in @($Keybindings | Where-Object { $null -ne $_ })) { Set-WtRecordedKeybinding -Root $wt -Binding $kb -Section $entry.bindings -Baseline $baseline -LegacyBaseline $legacyBaseline } + if ($PSBoundParameters.ContainsKey('Keybindings') -and $null -ne $Keybindings) { + Remove-WtRetiredKeybinding -Root $wt -Keybindings $Keybindings -Section $entry.bindings -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. @@ -1062,27 +1203,27 @@ function Get-WtProfileBackups { }) } -# 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) +# Resolve the oh-my-posh executable, preferring direct installs over the WindowsApps alias. 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') + [System.IO.Path]::Combine($env:LOCALAPPDATA, 'Programs\oh-my-posh\bin\oh-my-posh.exe'), + [System.IO.Path]::Combine($env:LOCALAPPDATA, 'Programs\oh-my-posh\oh-my-posh.exe'), + [System.IO.Path]::Combine($env:ProgramFiles, 'oh-my-posh\bin\oh-my-posh.exe') ) $pf86 = [System.Environment]::GetEnvironmentVariable('ProgramFiles(x86)', 'Process') if ($pf86) { - $candidatePaths += (Join-Path $pf86 'oh-my-posh\bin\oh-my-posh.exe') + $candidatePaths += [System.IO.Path]::Combine($pf86, 'oh-my-posh\bin\oh-my-posh.exe') } - $resolvedPath = Get-ExternalCommandPath -CommandName 'oh-my-posh' - $windowsAppsRoot = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' + $resolvedPath = Find-ProfileExecutable -Name 'oh-my-posh' + $windowsAppsRoot = [System.IO.Path]::Combine($env:LOCALAPPDATA, 'Microsoft\WindowsApps') if ($resolvedPath -and $resolvedPath -notlike "$windowsAppsRoot*") { return $resolvedPath } - foreach ($candidatePath in ($candidatePaths | Select-Object -Unique)) { - if (-not (Test-Path -LiteralPath $candidatePath)) { continue } + foreach ($candidatePath in $candidatePaths) { + if (-not [System.IO.File]::Exists($candidatePath)) { continue } $candidateDir = Split-Path -Path $candidatePath -Parent $pathEntries = @($env:PATH -split ';' | Where-Object { $_ }) @@ -1093,15 +1234,43 @@ function Get-OhMyPoshExecutablePath { 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. + # The WindowsApps alias is the only entry point for MSIX installs, so it is the last resort. if ($resolvedPath) { return $resolvedPath } + # Full command discovery still covers aliases and non-.exe shims when the fast probe found nothing. + return Get-ExternalCommandPath -CommandName 'oh-my-posh' +} + +# Return the managed location of the portable oh-my-posh build (also the first candidate in Get-OhMyPoshExecutablePath). +function Get-OhMyPoshPortablePath { + [System.IO.Path]::Combine($env:LOCALAPPDATA, 'Programs\oh-my-posh\bin\oh-my-posh.exe') +} + +# Return the version of an executable from its version resource, or $null when it has none and -VersionArgument is not given. +function Get-PspExecutableVersion { + param([Parameter(Mandatory)][string]$Path, [string[]]$VersionArgument) + $text = $null + try { $text = [string][System.Diagnostics.FileVersionInfo]::GetVersionInfo($Path).ProductVersion } catch { $null = $_ } + # The WindowsApps alias has no version resource, so the program itself is asked when -VersionArgument is given. + if ([string]::IsNullOrWhiteSpace($text) -and $VersionArgument) { + try { $text = [string](@(& $Path @VersionArgument 2>$null) | Where-Object { $_ -match '\d+\.\d+' } | Select-Object -First 1) } catch { $null = $_ } + } + $match = [regex]::Match([string]$text, '\d+\.\d+(\.\d+){0,2}') + $version = $null + if ($match.Success -and [version]::TryParse($match.Value, [ref]$version)) { return $version } return $null } -# Return OMP install path and kind (windowsapps vs direct) for upgrade logic +# Return $false for an oh-my-posh build older than the pinned minimum; a build whose version cannot be read is not refused. +function Test-PspOmpVersionSafe { + param([Parameter(Mandatory)][string]$Path) + $minimum = [string](Get-PspComponentPins).OhMyPoshMinimum + if (-not $minimum) { return $true } + $version = Get-PspExecutableVersion -Path $Path -VersionArgument 'version' + return ($null -eq $version -or $version -ge [version]$minimum) +} + +# Return OMP install path and kind (portable, windowsapps or direct) for upgrade logic; portable means installed by this profile. function Get-OhMyPoshInstallInfo { $path = Get-OhMyPoshExecutablePath if (-not $path) { @@ -1112,13 +1281,102 @@ function Get-OhMyPoshInstallInfo { } $windowsAppsRoot = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' - $installKind = if ($path -like "$windowsAppsRoot*") { 'windowsapps' } else { 'direct' } + $portablePath = Get-OhMyPoshPortablePath + $portableMarker = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($portablePath), 'psp-managed.txt') + $installKind = if ($path -like "$windowsAppsRoot*") { 'windowsapps' } + elseif ([string]::Equals($path, $portablePath, [System.StringComparison]::OrdinalIgnoreCase) -and [System.IO.File]::Exists($portableMarker)) { 'portable' } + else { 'direct' } return [PSCustomObject]@{ Path = $path InstallKind = $installKind } } +# Download the pinned portable oh-my-posh build, verify its SHA-256 and Authenticode signer, and install it to the managed location. +function Install-OhMyPoshPortable { + [CmdletBinding(SupportsShouldProcess)] + param( + # Reinstall even when the installed build already matches the pinned version. + [switch]$Force + ) + + # The MSIX build that winget installs pays package activation on every launch (about 30 ms per prompt), which the portable build avoids. + $targetPath = Get-OhMyPoshPortablePath + if (-not $PSCmdlet.ShouldProcess($targetPath, 'Install or update the portable oh-my-posh build')) { return $null } + + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + $asset = switch ($arch) { + 'X64' { 'posh-windows-amd64.exe' } + 'Arm64' { 'posh-windows-arm64.exe' } + default { $null } + } + if (-not $asset) { + Write-Warning "No portable oh-my-posh build exists for OS architecture '$arch'." + return $null + } + + $ProgressPreference = 'SilentlyContinue' + # The version and hash come from the profile's pins, so a newer release arrives only through a profile update. + $pins = Get-PspComponentPins + $version = [string]$pins.OhMyPosh + $expectedHash = if ($pins.OhMyPoshSha256) { [string]$pins.OhMyPoshSha256[$asset] } else { $null } + if ($version -notmatch '^\d+\.\d+\.\d+$' -or $expectedHash -notmatch '^[0-9A-Fa-f]{64}$') { + Write-Warning "No pinned oh-my-posh build for $asset." + return $null + } + $tag = "v$version" + + $targetDir = [System.IO.Path]::GetDirectoryName($targetPath) + $markerPath = [System.IO.Path]::Combine($targetDir, 'psp-managed.txt') + if (-not $Force -and [System.IO.File]::Exists($targetPath) -and [System.IO.File]::Exists($markerPath)) { + # A build newer than the pin (installed by a newer profile) is kept instead of being replaced with an older one. + $installedVersion = Get-PspExecutableVersion -Path $targetPath + if ($installedVersion -and $installedVersion -ge [version]$version) { + Write-PspHost " Oh My Posh $installedVersion (portable) is already up to date." -ForegroundColor DarkGray + return $targetPath + } + } + + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ('psp-omp-' + [System.IO.Path]::GetRandomFileName()) + try { + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + $downloadedExe = Join-Path $tempDir $asset + Write-PspHost " Downloading oh-my-posh $version ($asset)..." -ForegroundColor Yellow + Invoke-DownloadWithRetry -Uri "https://github.com/JanDeDobbeleer/oh-my-posh/releases/download/$tag/$asset" -OutFile $downloadedExe -TimeoutSec 300 + + # The pinned hash and a valid signature from the oh-my-posh author must both match before anything is installed. + $actualHash = (Get-FileHash -LiteralPath $downloadedExe -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash.ToUpperInvariant()) { throw "SHA256 mismatch for $asset (expected $expectedHash, got $actualHash)" } + $signature = Get-AuthenticodeSignature -LiteralPath $downloadedExe + if ($signature.Status -ne 'Valid' -or [string]$signature.SignerCertificate.Subject -notmatch '^CN=Jan Joris De Dobbeleer(,|$)') { + throw "Authenticode verification failed (status: $($signature.Status), signer: $($signature.SignerCertificate.Subject))" + } + + # Renaming the current binary first lets the update succeed while another shell is running it. + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + $previousPath = $targetPath + '.old' + Remove-Item -LiteralPath $previousPath -Force -ErrorAction SilentlyContinue + if ([System.IO.File]::Exists($targetPath)) { Move-Item -LiteralPath $targetPath -Destination $previousPath -Force -ErrorAction Stop } + try { Move-Item -LiteralPath $downloadedExe -Destination $targetPath -Force -ErrorAction Stop } + catch { + if ([System.IO.File]::Exists($previousPath) -and -not [System.IO.File]::Exists($targetPath)) { Move-Item -LiteralPath $previousPath -Destination $targetPath -Force -ErrorAction SilentlyContinue } + throw + } + Remove-Item -LiteralPath $previousPath -Force -ErrorAction SilentlyContinue + # The marker tells the profile this build is managed, so Update-Tools keeps it current. + [System.IO.File]::WriteAllText($markerPath, "$tag`n", [System.Text.UTF8Encoding]::new($false)) + Write-PspHost " Oh My Posh $version installed (portable) at $targetPath" -ForegroundColor Green + return $targetPath + } + catch { + Write-Warning "Portable oh-my-posh install failed: $($_.Exception.Message)" + return $null + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Test-WingetPackageInstalled { param( [Parameter(Mandatory)] @@ -1129,7 +1387,8 @@ function Test-WingetPackageInstalled { if (-not $wingetPath) { return $false } try { - $wingetOutput = @(& $wingetPath list --id $Id --exact 2>&1) + # Output is captured, so winget must not stop at an agreement prompt nobody sees. + $wingetOutput = @(& $wingetPath list --id $Id --exact --source winget --accept-source-agreements --disable-interactivity 2>&1) $wingetText = (@($wingetOutput) -join [Environment]::NewLine).Trim() if ($LASTEXITCODE -ne 0 -or -not $wingetText) { return $false } if ($wingetText -match 'No installed package found') { return $false } @@ -1160,7 +1419,7 @@ function Get-OhMyPoshMsiProductCode { return $null } -# Resolve executable path for a profile tool (OMP uses Get-OhMyPoshInstallInfo, others use Get-Command) +# Resolve executable path for a profile tool (OMP uses Get-OhMyPoshInstallInfo, others use Get-ExternalCommandPath) function Get-ProfileToolExecutablePath { param( [Parameter(Mandatory)] @@ -1206,20 +1465,21 @@ function Get-ProfileToolVersionText { return $null } -# Invoke oh-my-posh with explicit arguments and UTF-8 streams. +# Invoke oh-my-posh with explicit arguments and UTF-8 streams; -NoWait returns the running call for Complete-OhMyPoshCommand. function Invoke-OhMyPoshCommand { param( [Parameter(Mandatory)] [string]$ExecutablePath, [Parameter(Mandatory)] - [string[]]$Arguments + [string[]]$Arguments, + [switch]$NoWait ) - $process = New-Object System.Diagnostics.Process + $process = [System.Diagnostics.Process]::new() $startInfo = $process.StartInfo $startInfo.FileName = $ExecutablePath if ($startInfo.PSObject.Properties.Match('ArgumentList').Count -gt 0) { - $Arguments | ForEach-Object { $null = $startInfo.ArgumentList.Add($_) } + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } } else { $escapedArgs = $Arguments | ForEach-Object { @@ -1235,12 +1495,15 @@ function Invoke-OhMyPoshCommand { $startInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $startInfo.RedirectStandardError = $true $startInfo.RedirectStandardOutput = $true + # Redirected stdin keeps oh-my-posh away from keystrokes typed ahead into the shared console. + $startInfo.RedirectStandardInput = $true $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $true + # Sharing the interactive console avoids starting a hidden conhost.exe for every prompt. + $startInfo.CreateNoWindow = $false if ($PWD.Provider.Name -eq 'FileSystem') { try { - if (Test-Path -LiteralPath $PWD.ProviderPath) { + if ([System.IO.Directory]::Exists($PWD.ProviderPath)) { $startInfo.WorkingDirectory = $PWD.ProviderPath } } @@ -1249,12 +1512,54 @@ function Invoke-OhMyPoshCommand { } } - [void]$process.Start() + $pending = @{ + Process = $process + StartTask = $null + ExecutablePath = $ExecutablePath + ArgumentText = $Arguments -join "`n" + WorkingDirectory = $startInfo.WorkingDirectory + } + if ($NoWait -and $PSVersionTable.PSVersion.Major -ge 7) { + # A deferred call starts on a thread-pool thread to keep the first Process.Start off the load path; its small output waits in the pipe. + try { $pending.StartTask = [System.Threading.Tasks.Task[bool]]::Run([System.Func[bool]]$process.Start) } + catch { $pending.StartTask = $null } + } + if ($null -eq $pending.StartTask) { + # The child gets a PATH with Git for Windows' real git.exe first, swapped around Start because copying the environment costs more. + $childPath = Get-OhMyPoshChildPath + $restorePath = $null + if ($childPath) { + $restorePath = [System.Environment]::GetEnvironmentVariable('PATH') + [System.Environment]::SetEnvironmentVariable('PATH', $childPath) + } + try { [void]$process.Start() } + catch { + $process.Dispose() + throw + } + finally { + if ($null -ne $restorePath) { [System.Environment]::SetEnvironmentVariable('PATH', $restorePath) } + } + } + if ($NoWait) { return $pending } + return Complete-OhMyPoshCommand -Pending $pending +} + +# Wait for a started oh-my-posh call, forward its stderr, and return its stdout, or $null after PromptTimeoutMs so the plain prompt takes over. +function Complete-OhMyPoshCommand { + param( + [Parameter(Mandatory)] + [hashtable]$Pending + ) + + $process = $Pending.Process try { + if ($Pending.StartTask) { [void]$Pending.StartTask.GetAwaiter().GetResult() } + # Closing stdin right away gives oh-my-posh an immediate EOF if it ever reads input. + try { $process.StandardInput.Close() } catch { $null = $_ } $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. @@ -1279,6 +1584,41 @@ function Invoke-OhMyPoshCommand { } } +# Return PATH for oh-my-posh with Git for Windows' git binary directory placed just before its cmd\ launcher, or $null when that does not apply. +function Get-OhMyPoshChildPath { + $currentPath = [string]$env:PATH + if ($script:OhMyPoshChildPathSource -ceq $currentPath) { return $script:OhMyPoshChildPath } + $script:OhMyPoshChildPathSource = $currentPath + $script:OhMyPoshChildPath = $null + # cmd\git.exe only launches \\bin\git.exe, so pointing git lookups at the real binary skips one process per call. + if ([System.IO.Path]::DirectorySeparatorChar -ne '\') { return $null } + $launcher = Find-ProfileExecutable -Name 'git' + if (-not $launcher) { return $null } + $launcherDir = [System.IO.Path]::GetDirectoryName($launcher) + if (-not $launcherDir.EndsWith('\cmd', [System.StringComparison]::OrdinalIgnoreCase)) { return $null } + $gitRoot = $launcherDir.Substring(0, $launcherDir.Length - 4) + foreach ($toolchain in 'ucrt64', 'mingw64', 'clangarm64', 'mingw32') { + $binDir = $gitRoot + '\' + $toolchain + '\bin' + if (-not [System.IO.File]::Exists($binDir + '\git.exe')) { continue } + # Insert the binary directory right before the launcher's PATH entry, with or without its trailing backslash. + $padded = ';' + $currentPath + ';' + foreach ($entry in $launcherDir, ($launcherDir + '\')) { + $at = $padded.IndexOf(';' + $entry + ';', [System.StringComparison]::OrdinalIgnoreCase) + if ($at -lt 0) { continue } + $script:OhMyPoshChildPath = ($padded.Substring(0, $at + 1) + $binDir + $padded.Substring($at)).Trim(';') + return $script:OhMyPoshChildPath + } + return $null + } + return $null +} + +# Build a snapshot of the process environment so a prefetched prompt is only reused when no variable changed. +function Get-ProfileEnvironmentFingerprint { + $variables = [System.Environment]::GetEnvironmentVariables() + (@($variables.Keys) -join [char]1) + [char]2 + (@($variables.Values) -join [char]1) +} + # Gather context for oh-my-posh prompt rendering. function Get-OhMyPoshPromptContext { param( @@ -1379,10 +1719,12 @@ function Get-OhMyPoshPromptText { [string]$ConfigPath, [bool]$OriginalSuccess = $true, [AllowNull()] - [object]$OriginalLastExitCode = 0 + [object]$OriginalLastExitCode = 0, + # Start the render without waiting and keep it for the next primary prompt. + [switch]$Prefetch ) - if (-not (Test-Path -LiteralPath $ConfigPath)) { + if (-not [System.IO.File]::Exists($ConfigPath)) { throw "oh-my-posh config not found: $ConfigPath" } @@ -1411,6 +1753,39 @@ function Get-OhMyPoshPromptText { } } + if ($Prefetch) { + $environment = Get-ProfileEnvironmentFingerprint + $script:OhMyPoshPromptPrefetch = @{ + Pending = Invoke-OhMyPoshCommand -ExecutablePath $ExecutablePath -Arguments $arguments -NoWait + Environment = $environment + } + return + } + + # Reuse the prefetched render only when executable, arguments, working directory and environment all match. + $prefetched = $script:OhMyPoshPromptPrefetch + if ($null -ne $prefetched -and $Type -eq 'primary') { + $script:OhMyPoshPromptPrefetch = $null + $pending = $prefetched.Pending + $workingDirectory = '' + if ($PWD.Provider.Name -eq 'FileSystem' -and [System.IO.Directory]::Exists($PWD.ProviderPath)) { $workingDirectory = $PWD.ProviderPath } + if ($pending.ExecutablePath -eq $ExecutablePath -and + $pending.ArgumentText -ceq ($arguments -join "`n") -and + $pending.WorkingDirectory -eq $workingDirectory -and + $prefetched.Environment -ceq (Get-ProfileEnvironmentFingerprint)) { + try { return Complete-OhMyPoshCommand -Pending $pending } + catch { Write-Verbose "Prefetched oh-my-posh render failed; rendering again: $_" } + } + else { + try { + if ($pending.StartTask) { [void]$pending.StartTask.GetAwaiter().GetResult() } + if (-not $pending.Process.HasExited) { $pending.Process.Kill() } + } + catch { $null = $_ } + $pending.Process.Dispose() + } + } + return Invoke-OhMyPoshCommand -ExecutablePath $ExecutablePath -Arguments $arguments } @@ -1537,6 +1912,33 @@ function Merge-PspUserSettings { return $result } +# Return what in an Oh My Posh theme object can run commands or read data on every prompt: command segments at any depth, +# template functions such as cmd or readFile anywhere, and extends, which pulls in a config that is not checked here. +function Get-PspOmpThemeRisk { + param([AllowNull()][object]$Theme) + $reasons = [System.Collections.Generic.List[string]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + if ($null -ne $Theme) { $pending.Push($Theme) } + while ($pending.Count -gt 0) { + $node = $pending.Pop() + if ($node -is [string]) { + # Template functions are lowercase calls; .Env and other fields start with a dot and are not flagged. + if ($node -cmatch '\{\{[^}]*(? [CmdletBinding(SupportsShouldProcess)] param() - if (-not (Get-Command winget -ErrorAction Ignore)) { - Write-Warning "winget not found. Update-Tools only supports winget-managed upgrades." - return + $hasWinget = [bool](Get-Command winget -ErrorAction Ignore) + if (-not $hasWinget) { + Write-Warning "winget not found. Only the portable Oh My Posh build can be updated." } $installed = foreach ($tool in $script:ProfileTools) { @@ -2144,22 +2611,47 @@ function Update-Tools { $tool = $toolEntry.Tool $toolPath = $toolEntry.ExecutablePath - if ($tool.UpgradeStrategy -eq 'preserve-direct' -and $tool.Cmd -eq 'oh-my-posh') { + if ($tool.UpgradeStrategy -eq 'portable' -and $tool.Cmd -eq 'oh-my-posh') { $ompInstall = Get-OhMyPoshInstallInfo - if ($ompInstall.InstallKind -eq 'direct') { + # A direct install older than the minimum stays, and the portable build installed here is what the prompt uses instead. + $ompTooOld = $ompInstall.InstallKind -eq 'direct' -and -not (Test-PspOmpVersionSafe -Path $ompInstall.Path) + if ($ompInstall.InstallKind -eq 'direct' -and -not $ompTooOld) { Write-PspHost "Skipping $($tool.Name) update to preserve direct/MSI install at $($ompInstall.Path)." -ForegroundColor DarkGray $preserved++ continue } + if ($ompTooOld) { + Write-Warning ('{0} at {1} is older than {2} and runs code taken from folder names (CVE-2026-73505); the portable build is installed for the prompt. Update or remove that install.' -f $tool.Name, $ompInstall.Path, (Get-PspComponentPins).OhMyPoshMinimum) + } + # The managed portable build updates in place and replaces an MSIX install. + if (-not $PSCmdlet.ShouldProcess($tool.Name, 'Install or update the portable build')) { continue } + $oldVer = Get-ProfileToolVersionText -Tool $tool -ExecutablePath $toolPath + Write-PspHost "Updating $($tool.Name) (portable build)..." -ForegroundColor Cyan + $portablePath = Install-OhMyPoshPortable -Confirm:$false + if ($portablePath) { + $newVer = Get-ProfileToolVersionText -Tool $tool -ExecutablePath $portablePath + if ($ompInstall.InstallKind -eq 'windowsapps' -or $newVer -ne $oldVer) { + Write-PspHost " $($tool.Name): $oldVer -> $newVer (portable)" -ForegroundColor Green + $upgraded++ + } + if ($ompInstall.InstallKind -eq 'windowsapps') { + Write-PspHost ' The MSIX build is no longer used. Remove it with: winget uninstall -e --id JanDeDobbeleer.OhMyPosh' -ForegroundColor DarkGray + } + continue + } + if ($ompInstall.InstallKind -ne 'windowsapps' -or -not $hasWinget) { + $failed++ + continue + } + Write-PspHost ' Falling back to a winget upgrade of the MSIX build.' -ForegroundColor Yellow } + elseif (-not $hasWinget) { continue } if (-not $PSCmdlet.ShouldProcess($tool.Name, 'winget upgrade')) { continue } - # Capture pre-upgrade version $oldVer = Get-ProfileToolVersionText -Tool $tool -ExecutablePath $toolPath Write-PspHost "Updating $($tool.Name)..." -ForegroundColor Cyan - winget upgrade --id $tool.Id --accept-source-agreements --accept-package-agreements + winget upgrade --id $tool.Id --exact --source winget --accept-source-agreements --accept-package-agreements if ($LASTEXITCODE -eq 0) { - # Refresh PATH after install. Update-SessionPathFromRegistry $newToolPath = Get-ProfileToolExecutablePath -Tool $tool $newVer = if ($newToolPath) { Get-ProfileToolVersionText -Tool $tool -ExecutablePath $newToolPath } else { $null } @@ -2174,14 +2666,21 @@ function Update-Tools { Write-PspHost " $($tool.Name): already up to date ($oldVer)" -ForegroundColor DarkGray } } + # winget answers 0x8A150014 for a tool it did not install (scoop, choco, by hand), which stays as it is. + elseif ($LASTEXITCODE -eq -1978335212) { + Write-PspHost " $($tool.Name): not installed through winget, preserved ($toolPath)." -ForegroundColor DarkGray + $preserved++ + } + # 0x8A15002B means no newer version applies. elseif ($LASTEXITCODE -ne -1978335189) { $failed++ } } - if ($upgraded -gt 0) { - Restart-TerminalToApply -Message "Tools updated. Restarting terminal..." - } + # The warning comes first because the restart ends this session. if ($failed -gt 0) { Write-Warning "$failed tool(s) failed to update. Check the output above." } + if ($upgraded -gt 0) { + Restart-TerminalToApply -Message "Tools updated. Restarting terminal..." + } if ($upgraded -eq 0 -and $failed -eq 0) { if ($preserved -gt 0) { Write-PspHost "All winget-managed tools are up to date. $preserved tool(s) were preserved." -ForegroundColor Green @@ -2208,6 +2707,8 @@ function Restart-TerminalToApply { if (-not $dir -or -not (Test-Path -LiteralPath $dir -PathType Container -ErrorAction SilentlyContinue)) { $dir = [Environment]::GetFolderPath('UserProfile') } + # Windows Terminal splits its command line at every ';', so a folder containing one is not passed to wt -d. + if ($dir.Contains(';')) { $dir = [Environment]::GetFolderPath('UserProfile') } $shellExe = Get-PspShellPath Write-PspHost $Message -ForegroundColor Green Start-Sleep -Seconds 2 @@ -2316,7 +2817,7 @@ function cdb { } # Pop the consumed entries (0..N-1). for ($i = 0; $i -lt $N; $i++) { $script:PSP.PwdHistory.RemoveAt(0) } - # Suppress auto-push on the Set-Location that follows - we already cleaned up the stack. + # Skip the history push for this Set-Location; the stack is already trimmed. $script:PSP.SuppressPwdHistoryPush = $true Set-Location -LiteralPath $target } @@ -2378,7 +2879,8 @@ function New-PspRcScriptBlock { $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 "'", "''") + # EscapeSingleQuotedStringContent also doubles the typographic quotes that PowerShell accepts as single quotes. + $prelude = "`$PSScriptRoot = '{0}'; `$PSCommandPath = '{1}'; " -f [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent((Split-Path -Parent $Path)), [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($Path) if ($offset -gt 0) { $prelude = "; $prelude" } [scriptblock]::Create($Text.Insert($offset, $prelude)) } @@ -2403,9 +2905,10 @@ function Invoke-PromptStage { } $script:PSP.LastPwd = $current Invoke-ProfileHook -EventName 'OnCd' - if (-not $script:PSP.Features.perDirProfiles) { return } - $rc = Join-Path $current '.psprc.ps1' - if (-not (Test-Path -LiteralPath $rc -PathType Leaf)) { return } + # Only file system folders can hold a .psprc.ps1; the direct file check keeps this cheap on every directory change. + if (-not $script:PSP.Features.perDirProfiles -or $PWD.Provider.Name -ne 'FileSystem') { return } + $rc = [System.IO.Path]::Combine($current, '.psprc.ps1') + if (-not [System.IO.File]::Exists($rc)) { 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 @@ -2431,6 +2934,17 @@ function Invoke-PromptStage { } } +# End the garbage collection pause that started with the profile load; later collections run as usual. +function Exit-PspNoGcRegion { + if (-not $script:PspNoGcRegion) { return } + $script:PspNoGcRegion = $false + try { + # A load that allocated more than the reservation has already left the region on its own. + if ([System.Runtime.GCSettings]::LatencyMode -eq [System.Runtime.GCLatencyMode]::NoGCRegion) { [System.GC]::EndNoGCRegion() } + } + catch { $null = $_ } +} + # 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) + "] $ " } @@ -2439,6 +2953,7 @@ function Get-PspPlainPrompt { function prompt { Invoke-PromptStage Get-PspPlainPrompt + Exit-PspNoGcRegion } $Host.UI.RawUI.WindowTitle = "PowerShell {0}{1}" -f $PSVersionTable.PSVersion.ToString(), $script:PSP.AdminSuffix @@ -2482,7 +2997,7 @@ function Resolve-PreferredEditor { return $script:ResolvedEditor } -# Open files with preferred editor (alias: edit) +# Open files in the preferred editor. function edit { <# .SYNOPSIS @@ -2645,8 +3160,14 @@ function admin { $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 ' ' + # Every value becomes a quoted literal behind the call operator, so characters such as ; or | never start a second elevated command. + $parts = foreach ($a in $args) { + $s = [string]$a + if ($s -match '^-[A-Za-z][\w-]*:?$') { $s } + elseif ($s -match '^(-[A-Za-z][\w-]*:)(.*)$') { $Matches[1] + "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($Matches[2]) + "'" } + else { "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($s) + "'" } + } + $command = '& ' + ($parts -join ' ') $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) if ($wt) { Start-Process -FilePath $wt.Source -Verb runAs -ArgumentList (ConvertTo-NativeArgumentLine @($shell, '-NoExit', '-EncodedCommand', $encoded)) @@ -2664,11 +3185,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. + Return the system boot time through WMI on PS5 or Get-Uptime -Since on PS7. #> if ($PSVersionTable.PSVersion.Major -eq 5) { $lastBoot = (Get-WmiObject win32_operatingsystem | Select-Object -First 1).LastBootUpTime @@ -2767,7 +3287,7 @@ function extract { function hb { <# .SYNOPSIS - Upload to hastebin. + Upload a file to a public paste (bin.christitus.com). #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param([Parameter(Position = 0)][string]$Path, [switch]$Force) @@ -2778,21 +3298,29 @@ function hb { $uri = "https://bin.christitus.com/documents" $sizeKb = [math]::Round((Get-Item -LiteralPath $Path).Length / 1KB, 1) - if (-not $Force -and -not $PSCmdlet.ShouldProcess($uri, "Publish '$Path' (${sizeKb} KB) to a PUBLIC paste")) { return } + $action = "Publish '$Path' (${sizeKb} KB) to a PUBLIC paste" + # -Force skips the prompt, but -WhatIf still prevents the upload. + if ($WhatIfPreference) { $null = $PSCmdlet.ShouldProcess($uri, $action); return } + if (-not $Force -and -not $PSCmdlet.ShouldProcess($uri, $action)) { return } if ($Force) { Write-Warning "Uploading '$Path' (${sizeKb} KB) to a PUBLIC paste: $uri" } try { $response = Invoke-RestMethod -Uri $uri -Method Post -Body $Content -ErrorAction Stop -TimeoutSec 10 -UseBasicParsing $hasteKey = $response.key $url = "https://bin.christitus.com/$hasteKey" - Set-Clipboard $url - Write-Output "$url copied to clipboard." } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } Write-Error "Failed to upload the document. Error: $_" + return + } + # The paste is public once uploaded, so its URL is printed even when the clipboard is unavailable (for example over SSH). + try { + Set-Clipboard $url -ErrorAction Stop + Write-Output "$url copied to clipboard." } + catch { Write-Output "$url (could not copy to the clipboard: $($_.Exception.Message))" } } -# Grep Utility (PS5-compatible, no dependencies) +# Search files or pipeline input with ripgrep when installed, otherwise Select-String. function grep { <# .SYNOPSIS @@ -2846,21 +3374,25 @@ function sed($file, $find, $replace) { 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)) { + if (-not $file -or -not (Test-Path -LiteralPath $file -PathType Leaf)) { Write-Error "File not found: $file" return } if ($null -eq $find -or $find -eq '') { Write-Error "Usage: sed "; return } if ($null -eq $replace) { $replace = '' } - $resolved = (Resolve-Path -LiteralPath $file).Path + # ProviderPath is the plain file system path, also when the location is a PSDrive the .NET file APIs do not know. + $resolved = (Resolve-Path -LiteralPath $file).ProviderPath $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) + $updated = $content.Replace([string]$find, [string]$replace) + # A file without the text is left as it is, with no rewrite and no .bak. + if ($updated -ceq $content) { Write-PspHost "No occurrences of '$find' in $file." -ForegroundColor DarkGray; return } # 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) + [System.IO.File]::WriteAllText($resolved, $updated, $encoding) } # Show the full path of a command @@ -2887,7 +3419,7 @@ function file { 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 + $fullPath = $resolved.ProviderPath $item = Get-Item -LiteralPath $fullPath if ($item.PSIsContainer) { Write-PspHost "$($fullPath): directory"; return } $size = $item.Length @@ -3113,8 +3645,6 @@ function trash { # 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 { <# @@ -3138,10 +3668,9 @@ function dtop { } # Enhanced Listing (eza - modern ls replacement with icons and git status) -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. +if (Find-ProfileExecutable -Name 'eza') { + # la/ll/lt leave the built-in ls alias (Get-ChildItem) intact. + # Pass --icons=WHEN to eza, since a bare --icons swallows the next path. function la { <# .SYNOPSIS @@ -3166,35 +3695,34 @@ if (Get-Command eza -CommandType Application -ErrorAction Ignore) { } else { 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 + # Fallback listing when eza is not installed; arguments such as a path pass through like the eza versions. function la { <# .SYNOPSIS - Eza listing with hidden. + Listing with hidden items (Get-ChildItem fallback). #> - Get-ChildItem -Force | Format-Table -AutoSize + Get-ChildItem -Force @args | Format-Table -AutoSize } function ll { <# .SYNOPSIS - Eza long + git listing. + Long listing with hidden items (Get-ChildItem fallback). #> - Get-ChildItem -Force | Format-Table Mode, LastWriteTime, Length, Name -AutoSize + Get-ChildItem -Force @args | Format-Table Mode, LastWriteTime, Length, Name -AutoSize } function lt { <# .SYNOPSIS - Eza tree listing. + Recursive listing to depth 2 (Get-ChildItem fallback). #> - Get-ChildItem -Recurse -Depth 2 | Format-Table -AutoSize + Get-ChildItem -Recurse -Depth 2 @args | Format-Table -AutoSize } } # Syntax-highlighted file viewer (bat - modern cat replacement) if (-not $env:BAT_THEME) { $env:BAT_THEME = "TwoDark" } -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. +if (Find-ProfileExecutable -Name 'bat') { + # bcat leaves the built-in cat alias (Get-Content) intact. function bcat { <# .SYNOPSIS @@ -3211,9 +3739,9 @@ else { function gs { <# .SYNOPSIS - Git status. + Git status; extra arguments pass through. #> - git status + git status @args } # Git add all @@ -3225,8 +3753,7 @@ function ga { 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. +# Named gcmsg so the built-in gc alias (Get-Content) stays intact. function gcmsg { <# .SYNOPSIS @@ -3243,18 +3770,18 @@ function gcmsg { function gpush { <# .SYNOPSIS - Git push. + Git push; extra arguments such as a remote, branch or -u pass through. #> - git push + git push @args } # Git pull (gl is a built-in alias, so this is named gpull) function gpull { <# .SYNOPSIS - Git pull. + Git pull; extra arguments such as --rebase pass through. #> - git pull + git pull @args } # Jump to github directory via zoxide @@ -3320,8 +3847,11 @@ function flushdns { .SYNOPSIS Clear DNS cache. #> - Clear-DnsClientCache - Write-PspHost "DNS has been flushed" + try { + Clear-DnsClientCache -ErrorAction Stop + Write-PspHost "DNS has been flushed" + } + catch { Write-Error "Could not flush the DNS cache (an elevated shell may be required): $($_.Exception.Message)" } } # Network Diagnostics @@ -3351,7 +3881,7 @@ function ports { } } -# Check if a specific port is open on a host (defaults to localhost) +# Check whether a TCP port is open on a host. function checkport { <# .SYNOPSIS @@ -3439,6 +3969,36 @@ function checksum { } } +# Copy text so that Windows keeps it out of clipboard history and cloud sync (the formats password managers set). +function Set-PspSensitiveClipboard { + param([Parameter(Mandatory)][string]$Text) + # The WinForms clipboard needs a single-threaded apartment, which PowerShell 7's own thread is not. + $runspace = [runspacefactory]::CreateRunspace() + $runspace.ApartmentState = [System.Threading.ApartmentState]::STA + $runspace.Open() + $shell = [powershell]::Create() + try { + $shell.Runspace = $runspace + $null = $shell.AddScript({ + param($Value) + Add-Type -AssemblyName System.Windows.Forms + $data = New-Object System.Windows.Forms.DataObject + $data.SetText($Value, [System.Windows.Forms.TextDataFormat]::UnicodeText) + $data.SetData('ExcludeClipboardContentFromMonitorProcessing', (New-Object System.IO.MemoryStream (, [byte[]](0, 0, 0, 0)))) + $data.SetData('CanIncludeInClipboardHistory', (New-Object System.IO.MemoryStream (, [byte[]](0, 0, 0, 0)))) + $data.SetData('CanUploadToCloudClipboard', (New-Object System.IO.MemoryStream (, [byte[]](0, 0, 0, 0)))) + # True keeps the text on the clipboard after this runspace closes. + [System.Windows.Forms.Clipboard]::SetDataObject($data, $true) + }).AddArgument($Text) + $null = $shell.Invoke() + if ($shell.HadErrors) { throw $shell.Streams.Error[0].Exception } + } + finally { + $shell.Dispose() + $runspace.Dispose() + } +} + # Generate a random password of specified length (default 20) and copy to clipboard function genpass { <# @@ -3471,12 +4031,18 @@ function genpass { $password = $result.ToString() # 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-PspHost "Password copied to clipboard." -ForegroundColor Green + Set-PspSensitiveClipboard -Text $password + Write-PspHost "Password copied to clipboard (kept out of clipboard history)." -ForegroundColor Green } catch { - Write-Warning "Clipboard unavailable ($($_.Exception.Message)); showing the password once below:" - Write-PspHost $password -ForegroundColor Yellow + try { + Set-Clipboard $password -ErrorAction Stop + Write-PspHost "Password copied to clipboard; Windows clipboard history may keep it." -ForegroundColor Yellow + } + catch { + Write-Warning "Clipboard unavailable ($($_.Exception.Message)); showing the password once below:" + Write-PspHost $password -ForegroundColor Yellow + } } # Keep the plaintext out of the success stream and terminal scrollback. return @@ -3607,7 +4173,7 @@ function vtscan { } } $boundary = [guid]::NewGuid().ToString('N') - $fileBytes = [System.IO.File]::ReadAllBytes($resolved.Path) + $fileBytes = [System.IO.File]::ReadAllBytes($resolved.ProviderPath) $enc = [System.Text.Encoding]::GetEncoding('iso-8859-1') $safeName = $file.Name -replace '["\r\n]', '_' $header = "--$boundary`r`nContent-Disposition: form-data; name=`"file`"; filename=`"$safeName`"`r`nContent-Type: application/octet-stream`r`n`r`n" @@ -3636,7 +4202,7 @@ function vtscan { } } -if (-not (Get-Command vt.exe -CommandType Application -ErrorAction Ignore)) { +if (-not (Find-ProfileExecutable -Name 'vt.exe')) { # Fallback when vt-cli not installed: show install instructions function vt { <# @@ -3650,7 +4216,7 @@ if (-not (Get-Command vt.exe -CommandType Application -ErrorAction Ignore)) { } # Docker Shortcuts (conditional) -if (Get-Command docker -CommandType Application -ErrorAction Ignore) { +if (Find-ProfileExecutable -Name 'docker') { # dps/dpa/dimg: list containers and images function dps { <# @@ -3697,14 +4263,21 @@ if (Get-Command docker -CommandType Application -ErrorAction Ignore) { try { docker exec -it $Container $Shell } finally { Pop-TabTitle $old } } - # dstop: stop all running containers; dprune: system prune. Both confirm first (-Confirm:$false skips). + # dstop: stop the named containers, or every running one when none are named; dprune: system prune. Both confirm first (-Confirm:$false skips). function dstop { <# .SYNOPSIS - Stop all running containers. + Stop named containers, or all running ones. + .EXAMPLE + dstop web db + Stop the containers named web and db. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] - param() + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Container) + if ($Container) { + if ($PSCmdlet.ShouldProcess(($Container -join ', '), 'docker stop')) { docker stop @Container } + return + } $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 } @@ -3721,7 +4294,7 @@ if (Get-Command docker -CommandType Application -ErrorAction Ignore) { } # Define the WSL wrapper and helpers only when wsl.exe is available. -if (Get-Command wsl.exe -CommandType Application -ErrorAction Ignore) { +if (Find-ProfileExecutable -Name 'wsl.exe') { # Show the WSL distro in the tab title and invoke wsl.exe without recursion. function wsl { <# @@ -3798,7 +4371,8 @@ if (Get-Command wsl.exe -CommandType Application -ErrorAction Ignore) { $normalized = $Path -replace '\\', '/' $wslArgs = @() if ($Distro) { $wslArgs += '-d', $Distro } - $wslArgs += 'wslpath', '-a', '--', $normalized + # -e runs wslpath without the distro's shell, so $(...) or backticks in a path are never evaluated. + $wslArgs += '-e', 'wslpath', '-a', '--', $normalized $result = (& wsl.exe @wslArgs 2>&1) if ($LASTEXITCODE -ne 0) { Write-Error ($result -join ' '); return } ($result | Select-Object -First 1).ToString().Trim() @@ -3818,7 +4392,7 @@ if (Get-Command wsl.exe -CommandType Application -ErrorAction Ignore) { $normalized = $Path -replace '\\', '/' $wslArgs = @() if ($Distro) { $wslArgs += '-d', $Distro } - $wslArgs += 'wslpath', '-w', '--', $normalized + $wslArgs += '-e', 'wslpath', '-w', '--', $normalized $result = (& wsl.exe @wslArgs 2>&1) if ($LASTEXITCODE -ne 0) { Write-Error ($result -join ' '); return } ($result | Select-Object -First 1).ToString().Trim() @@ -3835,13 +4409,15 @@ if (Get-Command wsl.exe -CommandType Application -ErrorAction Ignore) { if ($Distro) { if ($PSCmdlet.ShouldProcess($Distro, 'wsl --terminate')) { & wsl.exe --terminate $Distro - Write-PspHost "Terminated: $Distro" -ForegroundColor Green + if ($LASTEXITCODE -eq 0) { Write-PspHost "Terminated: $Distro" -ForegroundColor Green } + else { Write-Error "wsl --terminate $Distro failed (exit $LASTEXITCODE)." } } } else { if ($PSCmdlet.ShouldProcess('all WSL distros', 'wsl --shutdown')) { & wsl.exe --shutdown - Write-PspHost "All WSL distros stopped." -ForegroundColor Green + if ($LASTEXITCODE -eq 0) { Write-PspHost "All WSL distros stopped." -ForegroundColor Green } + else { Write-Error "wsl --shutdown failed (exit $LASTEXITCODE)." } } } } @@ -3986,12 +4562,15 @@ function svc { } while ($Live) } -# Reload profile in current session (useful after editing profile_user.ps1 or user-settings.json) +# Reload the profile; only a dot-sourced call (. reload) brings new and changed functions into the session. function reload { <# .SYNOPSIS - Reload the profile in-place. + Reload the profile; run as '. reload' to refresh functions too. #> + if ($MyInvocation.InvocationName -ne '.') { + Write-Warning "Functions only reload when dot-sourced: run '. reload' or '. $PROFILE'. Global settings are refreshed now." + } . $PROFILE } @@ -4035,7 +4614,13 @@ function Test-ProfileHealth { # Managed tools foreach ($tool in $script:ProfileTools) { $path = Get-ProfileToolExecutablePath -Tool $tool - if ($path) { + if ($path -and $tool.Cmd -eq 'oh-my-posh' -and -not (Test-PspOmpVersionSafe -Path $path)) { + $results += [pscustomobject]@{ Category = 'Tools'; Check = $tool.Name; Status = 'FAIL'; Detail = ('{0} is older than {1} and runs code taken from folder names (CVE-2026-73505), so the prompt does not use it; run Update-Tools or update that install' -f $path, (Get-PspComponentPins).OhMyPoshMinimum) } + } + elseif ($path -and $tool.Cmd -eq 'oh-my-posh' -and (Get-OhMyPoshInstallInfo).InstallKind -eq 'windowsapps') { + $results += [pscustomobject]@{ Category = 'Tools'; Check = $tool.Name; Status = 'WARN'; Detail = "$path (MSIX build adds ~30 ms per prompt; run Update-Tools to switch to the portable build)" } + } + elseif ($path) { $results += [pscustomobject]@{ Category = 'Tools'; Check = $tool.Name; Status = 'OK'; Detail = $path } } else { @@ -4230,11 +4815,11 @@ function Test-ProfileHealth { } Set-Alias -Name psp-doctor -Value Test-ProfileHealth -Scope Script -# Clear regenerable profile and Oh My Posh caches before a terminal restart; settings, plugins and install state are kept. +# Clear regenerable profile and Oh My Posh caches before a terminal restart; settings, plugins, install state and theme files are kept. function Clear-ProfileCache { <# .SYNOPSIS - Reset caches except user settings. + Reset caches; settings and theme files are kept. #> [CmdletBinding(SupportsShouldProcess = $true)] param() @@ -4244,10 +4829,10 @@ function Clear-ProfileCache { Write-PspHost "No cache directory found." -ForegroundColor Yellow return } - # 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') + # State files (uninstall needs them) and the downloaded theme/config (only setup or Update-Profile restore them) are kept. + $preservedNames = @('user-settings.json', 'user-settings.json.bak', 'plugins', 'telemetry.owned', 'wt-changes.json', 'installed.json', 'applied-commit.sha', 'logs', 'theme.json', 'terminal-config.json') # 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' }) + $items = @(Get-ChildItem -LiteralPath $cacheDir -Force -ErrorAction SilentlyContinue | Where-Object { $preservedNames -notcontains $_.Name -and $_.Name -notlike 'bg-*.png' -and $_.Name -notlike '*.omp.json' }) if ($items.Count -eq 0) { if ($PSCmdlet.ShouldProcess('Oh My Posh caches', 'Clear')) { Clear-OhMyPoshCaches -Quiet } Write-PspHost "Cache is already clean." -ForegroundColor Green @@ -4278,7 +4863,7 @@ function Invoke-ProfileWizard { [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. + # Ignored; the wizard always runs unelevated, in a child PowerShell in this window. [switch]$NoElevate, [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedSha256, @@ -4356,7 +4941,7 @@ function Invoke-ProfileWizard { & (Get-PspShellPath) @shellArgs $exitCode = $LASTEXITCODE if ($exitCode -eq 0) { - Write-PspHost "Wizard complete. Reload your shell (run 'reload') to apply changes." -ForegroundColor Green + Write-PspHost "Wizard complete. Run '. reload' or open a new terminal to apply changes." -ForegroundColor Green } else { Write-Warning "Wizard exited with code $exitCode. Review the output above." @@ -4461,8 +5046,8 @@ function Uninstall-Profile { catch { Write-Warning " Could not revert recorded changes in ${wtSettingsPath}: $($_.Exception.Message)" } } 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 + # No change record: restore the oldest backup and keep the current file next to it. + $source = @($profileBackups | Where-Object { $_.Name -like '*.psp-original' }) + @($profileBackups | Where-Object { $_.Name -like '*.bak' } | Sort-Object Name) | 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 { @@ -4547,6 +5132,7 @@ function Uninstall-Profile { } # Phase 3b: Temporary profile artifacts + # Every temp name the profile and setup.ps1 create; psp-wizard-state.json is where older setups kept the wizard state. $tempArtifactPatterns = @( 'psp-wizard-state.json' 'psp-profile-*.ps1' @@ -4554,6 +5140,10 @@ function Uninstall-Profile { 'psp-terminal-*.json' 'psp-setup-*' 'psp-reconfigure-*' + 'psp-omp-*' + 'psp-sums-*' + 'psp-font-*' + 'psp-wlan-*' ) foreach ($pattern in $tempArtifactPatterns) { $orphans = Get-ChildItem -LiteralPath $env:TEMP -Filter $pattern -Force -ErrorAction SilentlyContinue @@ -4569,7 +5159,7 @@ function Uninstall-Profile { if ($PSCmdlet.ShouldProcess('Oh My Posh caches', 'Clear')) { Clear-OhMyPoshCaches -Quiet } # 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) + $isCiOrAgent = ((([bool]$env:CI -and $env:CI -ne 'false' -and $env:CI -ne '0') -or $env:AI_AGENT) -and -not $Force) $psfzfPresent = [bool](Get-Module -ListAvailable -Name PSFzf) if ($psfzfPresent -and -not $RemoveTools) { $preserved += 'PSFzf module (use -RemoveTools to remove)' @@ -4626,6 +5216,23 @@ function Uninstall-Profile { $ompMsiProductCode = $null if ($tool.Cmd -eq 'oh-my-posh') { + # Remove the profile-managed portable build first; winget/MSI installs are handled below. + $portableOmp = Get-OhMyPoshPortablePath + $portableOmpDir = [System.IO.Path]::GetDirectoryName($portableOmp) + $portableOmpMarker = [System.IO.Path]::Combine($portableOmpDir, 'psp-managed.txt') + if ([System.IO.File]::Exists($portableOmpMarker) -and $PSCmdlet.ShouldProcess($portableOmp, 'Remove portable Oh My Posh build')) { + foreach ($portableFile in @($portableOmp, ($portableOmp + '.old'), $portableOmpMarker)) { + Remove-Item -LiteralPath $portableFile -Force -ErrorAction SilentlyContinue + } + foreach ($emptyDir in @($portableOmpDir, [System.IO.Path]::GetDirectoryName($portableOmpDir))) { + if ([System.IO.Directory]::Exists($emptyDir) -and @([System.IO.Directory]::GetFileSystemEntries($emptyDir)).Count -eq 0) { + Remove-Item -LiteralPath $emptyDir -Force -ErrorAction SilentlyContinue + } + } + if ([System.IO.File]::Exists($portableOmp)) { Write-Warning " Could not remove $portableOmp (in use?)." } + else { Write-PspHost ' Removed portable Oh My Posh build' -ForegroundColor Green } + $toolPath = Get-ProfileToolExecutablePath -Tool $tool + } if ($wingetManaged) { $removalMode = 'winget' } @@ -4692,11 +5299,9 @@ function Uninstall-Profile { } else { $preserved += 'Managed tools (use -RemoveTools to uninstall)' } - # 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. + # Phase 6: Nerd Fonts (opt-in); both the per-user and the machine-wide store are swept, and only the latter 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. + # The font filter follows what setup installed: user-settings.json, then terminal-config.json, then the default. $fontDisplayName = 'CaskaydiaCove NF' try { # Read before the cache cleanup removed it. @@ -4708,11 +5313,9 @@ function Uninstall-Profile { $fontDisplayName = [string]$userSettings.defaults.font.face } $tokens = @($fontDisplayName -split '\s+' | Where-Object { $_ }) - # Registry entries keep the display form ("CaskaydiaCove NF Bold (TrueType)")... + # Registry names use the display form ("CaskaydiaCove NF Bold (TrueType)"). $regNamePattern = ($tokens | ForEach-Object { [regex]::Escape($_) }) -join '.*' - # ...but on disk the same family is written "CaskaydiaCoveNerdFont-Bold.ttf", so the - # abbreviated "NF" never matches a filename. Match family prefix + the NerdFont marker, - # which also keeps this away from Microsoft's own Cascadia Code files. + # File names use the family plus a NerdFont marker ("CaskaydiaCoveNerdFont-Bold.ttf"), which also excludes Microsoft's own Cascadia Code. $fileNamePattern = '^' + [regex]::Escape($tokens[0]) + '.*NerdFont' $fontExtensions = @('.ttf', '.otf') @@ -4728,8 +5331,7 @@ function Uninstall-Profile { $fontMatches = 0 $removedFontCount = 0 $skippedMachineScope = $false - # Under -WhatIf nothing is actually deleted, so the orphan sweep would re-list every file - # the registry pass already claimed. Track them to keep the plan honest. + # Files the registry pass already handled are tracked so -WhatIf does not list them twice. $handledFontFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($scope in $fontScopes) { if ($scope.NeedsAdmin -and -not $isElevated) { @@ -4747,8 +5349,9 @@ function Uninstall-Profile { if ($regEntries) { foreach ($prop in @($regEntries.PSObject.Properties | Where-Object { $_.Name -match $regNamePattern })) { $fontFile = [string]$prop.Value - # An entry without a file, or one pointing outside this scope's store, is left alone. - if (-not $fontFile) { continue } + # An entry without a file, one whose file is not a Nerd Font build (so a font such as Cascadia Code or Consolas is never + # unregistered), or one pointing outside this scope's store, is left alone. + if (-not $fontFile -or [System.IO.Path]::GetFileName($fontFile) -notmatch $fileNamePattern) { 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 @@ -4765,7 +5368,8 @@ function Uninstall-Profile { $fontMatches++ if ($PSCmdlet.ShouldProcess($fontFile, 'Remove font file')) { Remove-Item -LiteralPath $fontFile -Force -ErrorAction SilentlyContinue - $removedFontCount++ + # A font file in use stays on disk, so only files that are really gone are counted. + if (-not (Test-Path -LiteralPath $fontFile)) { $removedFontCount++ } } } if ($PSCmdlet.ShouldProcess($prop.Name, "Remove $($scope.Name) font registry entry")) { @@ -4781,7 +5385,7 @@ function Uninstall-Profile { $fontMatches++ if ($PSCmdlet.ShouldProcess($orphan.FullName, 'Remove font file')) { Remove-Item -LiteralPath $orphan.FullName -Force -ErrorAction SilentlyContinue - $removedFontCount++ + if (-not (Test-Path -LiteralPath $orphan.FullName)) { $removedFontCount++ } } } } @@ -4811,13 +5415,19 @@ function Uninstall-Profile { $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. + # A profile.original.ps1 that is a copy of this profile is not the user's profile. $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) { + # Only this profile is replaced or removed: a profile the user put there since (or an earlier uninstall restored) stays. + $profileIsOurs = Test-PspProfileFile -Path $mainProfile + $mainExists = Test-Path -LiteralPath $mainProfile + if ($mainExists -and -not $profileIsOurs) { + $preserved += $(if ($hasOriginal) { "$mainProfile (not this profile, so profile.original.ps1 was not copied over it; both are kept)" } else { "$mainProfile (not this profile)" }) + } + elseif ($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')) { try { @@ -4832,14 +5442,13 @@ function Uninstall-Profile { } } } - elseif (Test-Path -LiteralPath $mainProfile) { + elseif ($mainExists) { if ($PSCmdlet.ShouldProcess($mainProfile, 'Remove profile file')) { 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. + # The oldest copy may be the user's pre-install profile, so copies go only after a restore or with -RemoveUserData. $oldCopies = @(Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue) if ($oldCopies.Count -gt 0) { if ($originalRestored -or $RemoveUserData) { @@ -4902,7 +5511,6 @@ function weather { #> param([string]$City) $encoded = if ($City) { [System.Uri]::EscapeDataString($City) } else { '' } - # Try wttr.in first, fall back to Open-Meteo if unreachable $url = if ($encoded) { "https://wttr.in/${encoded}?format=3" } else { "https://wttr.in/?format=3" } try { $r = Invoke-RestMethod $url -TimeoutSec 10 -Headers @{ 'User-Agent' = 'curl' } -UseBasicParsing @@ -4936,6 +5544,8 @@ function weather { $temp = $wx.current.temperature_2m $unit = if ($wx.current_units -and $wx.current_units.temperature_2m) { $wx.current_units.temperature_2m } else { 'C' } Write-PspHost "$($loc.name): ${temp}${unit}" -ForegroundColor Cyan + # Open-Meteo's terms (CC BY 4.0) ask for this credit next to the data. + Write-PspHost 'Weather data by Open-Meteo.com' -ForegroundColor DarkGray } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } @@ -5009,6 +5619,9 @@ function speedtest { .SYNOPSIS Download speed test. #> + + # Windows PowerShell's progress bar slows the download it is measuring. + $ProgressPreference = 'SilentlyContinue' Write-PspHost "Testing download speed..." -ForegroundColor Cyan $url = "https://speed.cloudflare.com/__down?bytes=25000000" $start = Get-Date @@ -5062,13 +5675,14 @@ function eventlog { } # SSH & Remote -if (Get-Command ssh -CommandType Application -ErrorAction Ignore) { +if (Find-ProfileExecutable -Name 'ssh') { # 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. @@ -5085,9 +5699,7 @@ if (Get-Command ssh -CommandType Application -ErrorAction Ignore) { break } - # Add timeout/keepalive defaults only where neither the command line nor the host's effective - # config already set them, so the user's ~/.ssh/config is never overridden. ssh -G reports an - # unset ConnectTimeout as "none" and an unset ServerAliveInterval as "0". + # Add timeout/keepalive defaults only when neither the command line nor ssh -G sets them (none/0 mean unset). $effective = @{} if ($target) { try { @@ -5196,6 +5808,10 @@ function killport { Write-Warning "Port $Port belongs to $($proc.ProcessName) (PID $procId), a core Windows process; stopping it can crash or restart Windows. Skipping." continue } + if ($procId -eq $PID) { + Write-Warning "Port $Port belongs to this PowerShell session (PID $procId) - 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 { @@ -5230,14 +5846,14 @@ function Stop-ListeningPort { } $selected = @() if (Get-Command fzf -ErrorAction Ignore) { - # Format each row into a single fzf line; parse selection back to PID via last column. + # Format each row as one fzf line; the PID is read back from the third column. $lines = $rows | ForEach-Object { ('{0,-7} {1,-20} {2,-8} {3}' -f $_.Port, $_.Address, $_.PID, $_.Process) } $picked = $lines | fzf --multi --header='Tab to multi-select, Enter to kill' --prompt='killport> ' if (-not $picked) { Write-PspHost 'Nothing selected.' -ForegroundColor DarkGray; return } foreach ($line in @($picked)) { - # PID is the third whitespace-separated column in our formatted line. + # PID is the third whitespace-separated column. $cols = $line -split '\s+' | Where-Object { $_ } if ($cols.Count -ge 3) { $selected += ($rows | Where-Object { [string]$_.PID -eq $cols[2] } | Select-Object -First 1) @@ -5253,6 +5869,10 @@ function Stop-ListeningPort { Write-Warning "Skipping core Windows process $($row.Process) (PID $($row.PID)) on port $($row.Port)." continue } + if ($row.PID -eq $PID) { + Write-Warning "Skipping this PowerShell session (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 @@ -5369,11 +5989,11 @@ function Find-FileLocker { } } -# Escalate process termination from Stop-Process to taskkill for names, IDs, or pipeline input. +# Escalate process termination from Stop-Process to taskkill /F (with /T for -Tree) for names, IDs, or pipeline input. function Stop-StuckProcess { <# .SYNOPSIS - Escalating kill: Stop-Process -> taskkill /F -> /F /T. + Escalating kill: Stop-Process, then taskkill /F (/T with -Tree). #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')] param( @@ -5401,11 +6021,15 @@ function Stop-StuckProcess { foreach ($procId in $targets) { $p = Get-Process -Id $procId -ErrorAction SilentlyContinue if (-not $p) { Write-PspHost "PID $procId already gone." -ForegroundColor DarkGray; continue } - # Never escalate termination for core processes or system PIDs. + # Never escalate termination for core processes, system PIDs or this session. if ($procId -le 4 -or $script:ProtectedProcessNames -contains $p.ProcessName) { Write-Warning "Refusing to stop protected system process: $($p.ProcessName) (PID $procId)." continue } + if ($procId -eq $PID) { + Write-Warning "Skipping the current PowerShell session (PID $procId)." + continue + } $label = "$($p.ProcessName) (PID $procId)" if (-not $PSCmdlet.ShouldProcess($label, 'Stop process (escalating)')) { continue } # Stage 1: Stop-Process -Force @@ -5430,7 +6054,6 @@ 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 { <# @@ -5446,7 +6069,7 @@ function Remove-LockedItem { [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) + [switch]$KillSystem # Ignored: system processes are never stopped. ) $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue if (-not $resolved) { Write-Error "Path not found: $Path"; return } @@ -5468,8 +6091,8 @@ function Remove-LockedItem { Write-PspHost "Lockers found:" -ForegroundColor Cyan $lockers | Format-Table PID, Name, WindowTitle | Out-Host foreach ($l in $lockers) { - 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)." + if ($l.PID -le 4 -or $l.Name -eq 'System') { + Write-Warning "Skipping system process $($l.Name) (PID $($l.PID)); system processes are never stopped." continue } # One confirmation per process, now that the user can see what holds the lock. @@ -5523,7 +6146,7 @@ function http { Write-PspHost "Binary response ($contentTypeHeader), $($response.RawContentLength) bytes" -ForegroundColor Yellow } elseif ($contentTypeHeader -and $contentTypeHeader -match 'json') { - $response.Content | ConvertFrom-Json | ConvertTo-Json -Depth 10 + ConvertTo-ProfilePrettyJson -Text $response.Content } else { $response.Content @@ -5552,28 +6175,50 @@ function http { } } +# Re-indent JSON text while keeping a top-level array an array in both editions. +function ConvertTo-ProfilePrettyJson { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) + if ($PSVersionTable.PSVersion.Major -ge 7) { + return (ConvertTo-Json -InputObject (ConvertFrom-Json -InputObject $Text -NoEnumerate) -Depth 10) + } + # Windows PowerShell serializes a parsed top-level array as value/Count unless it is rewrapped. + $parsed = ConvertFrom-Json -InputObject $Text + if ($parsed -is [array]) { return (ConvertTo-Json -InputObject @($parsed) -Depth 10) } + ConvertTo-Json -InputObject $parsed -Depth 10 +} + # Pretty-print JSON from a file or pipeline input. function prettyjson { <# .SYNOPSIS Pretty-print JSON. + .EXAMPLE + Get-Content .\data.json | prettyjson + Re-indent JSON read from the pipeline; a file path works too. #> param( [Parameter(Position = 0)] - [string]$File + [string]$File, + # Pipeline lines are joined before parsing, so multi-line JSON from Get-Content works. + [Parameter(ValueFromPipeline)] + [AllowEmptyString()] + [string]$InputObject ) - try { - $jsonInput = @($input) - if ($jsonInput.Count -gt 0) { - ($jsonInput -join "`n") | ConvertFrom-Json | ConvertTo-Json -Depth 10 - } - elseif ($File) { - if (-not (Test-Path -LiteralPath $File)) { Write-Error "File not found: $File"; return } - Get-Utf8FileText $File | ConvertFrom-Json | ConvertTo-Json -Depth 10 + begin { $jsonInput = [System.Collections.Generic.List[string]]::new() } + process { if ($PSBoundParameters.ContainsKey('InputObject')) { $jsonInput.Add($InputObject) } } + end { + try { + if ($jsonInput.Count -gt 0) { + ConvertTo-ProfilePrettyJson -Text ($jsonInput -join "`n") + } + elseif ($File) { + if (-not (Test-Path -LiteralPath $File -PathType Leaf)) { Write-Error "File not found: $File"; return } + ConvertTo-ProfilePrettyJson -Text (Get-Utf8FileText $File) + } + else { Write-Error 'Usage: prettyjson or | prettyjson' } } - else { Write-Error 'Usage: prettyjson or | prettyjson' } + catch { Write-Error "Invalid JSON: $_" } } - catch { Write-Error "Invalid JSON: $_" } } # JWT decode (strips Bearer prefix, decodes header + payload without verification) @@ -5844,18 +6489,27 @@ function ipinfo { } } -# Quick timestamped backup of a file +# Quick timestamped backup of a file or folder, placed next to the original. function bak { <# .SYNOPSIS Timestamped backup. + .EXAMPLE + bak .\config.json + Copy config.json to config.json..bak; a folder is copied with its contents. #> param([Parameter(Mandatory)][string]$Path) - if (-not (Test-Path -LiteralPath $Path)) { Write-Error "File not found: $Path"; return } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + if (-not $item) { Write-Error "File not found: $Path"; return } + if ($item.PSIsContainer -and -not $item.Parent) { Write-Error "bak: a drive root cannot be backed up."; return } $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" - $dest = "$Path.$timestamp.bak" - Copy-Item -LiteralPath $Path -Destination $dest -Force - Write-PspHost "Backup: $dest" -ForegroundColor Green + # The copy sits next to the original even when the path ends in a separator, and folders keep their contents. + $dest = '{0}.{1}.bak' -f $item.FullName.TrimEnd('\', '/'), $timestamp + try { + Copy-Item -LiteralPath $item.FullName -Destination $dest -Force -Recurse:$item.PSIsContainer -ErrorAction Stop + Write-PspHost "Backup: $dest" -ForegroundColor Green + } + catch { Write-Error "Backup failed: $($_.Exception.Message)" } } # Repeat a command at intervals (like Linux watch) @@ -5967,7 +6621,6 @@ function Invoke-Clipboard { return } - # Insert clipboard contents into the prompt buffer without execution. try { if ($script:PSP.IsInteractive -and (Get-Module PSReadLine -ErrorAction SilentlyContinue)) { [Microsoft.PowerShell.PSConsoleReadLine]::Insert($clipboardText) @@ -6001,7 +6654,7 @@ Register-ArgumentCompleter -CommandName journal -ParameterName LogName -ScriptBl Get-WinEvent -ListLog * -ErrorAction SilentlyContinue | Where-Object { $_.LogName -like "$wordToComplete*" } | Select-Object -First 25 | - ForEach-Object { [System.Management.Automation.CompletionResult]::new("'$($_.LogName)'", $_.LogName, 'ParameterValue', $_.LogName) } + ForEach-Object { [System.Management.Automation.CompletionResult]::new(("'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_.LogName) + "'"), $_.LogName, 'ParameterValue', $_.LogName) } } catch { $null = $_ } } @@ -6035,13 +6688,6 @@ Register-ArgumentCompleter -CommandName nscan -ParameterName Mode -ScriptBlock { ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Key, $_.Key, 'ParameterValue', $_.Value) } } -# Defender scan modes -Register-ArgumentCompleter -CommandName defscan -ParameterName Mode -ScriptBlock { - param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) - $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters - @('Quick', 'Full') | Where-Object { $_ -like "$wordToComplete*" } | - ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } -} # Lint mode presets Register-ArgumentCompleter -CommandName lint -ParameterName Mode -ScriptBlock { @@ -6063,10 +6709,10 @@ Register-ArgumentCompleter -CommandName Remove-TrustedDirectory -ParameterName P $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters if (-not $script:PSP) { return } $script:PSP.TrustedDirs | Where-Object { $_ -like "*$wordToComplete*" } | - ForEach-Object { [System.Management.Automation.CompletionResult]::new("'$_'", $_, 'ParameterValue', $_) } + ForEach-Object { [System.Management.Automation.CompletionResult]::new(("'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_) + "'"), $_, 'ParameterValue', $_) } } -# Set-TerminalBackground: StretchMode / Alignment with human-readable descriptions +# Set-TerminalBackground -StretchMode with descriptions; parameters with only a ValidateSet complete natively. Register-ArgumentCompleter -CommandName Set-TerminalBackground -ParameterName StretchMode -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters @@ -6080,13 +6726,6 @@ Register-ArgumentCompleter -CommandName Set-TerminalBackground -ParameterName St ForEach-Object { [System.Management.Automation.CompletionResult]::new($_.Key, $_.Key, 'ParameterValue', $_.Value) } } -Register-ArgumentCompleter -CommandName Set-TerminalBackground -ParameterName Alignment -ScriptBlock { - param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) - $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters - @('center', 'left', 'top', 'right', 'bottom', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight') | - Where-Object { $_ -like "$wordToComplete*" } | - ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } -} # Complete WSL distro names when wsl.exe is available. Register-ArgumentCompleter -CommandName Enter-WslHere, ConvertTo-WslPath, ConvertTo-WindowsPath, Stop-Wsl, Get-WslIp, Get-WslFile, Show-WslTree, Open-WslExplorer -ParameterName Distro -ScriptBlock { @@ -6258,7 +6897,9 @@ function mtr { for ($j = 0; $j -lt $Count; $j++) { try { $p = Test-Connection -ComputerName $hop -Count 1 -ErrorAction Stop - if ($p) { + # PowerShell 7 returns a TimedOut status object instead of throwing, so only a successful reply counts. + $replied = $p -and (-not $p.PSObject.Properties['Status'] -or [string]$p.Status -eq 'Success') -and (-not $p.PSObject.Properties['StatusCode'] -or [int]$p.StatusCode -eq 0) + if ($replied) { $lat = if ($p.PSObject.Properties['ResponseTime']) { [int]$p.ResponseTime } elseif ($p.PSObject.Properties['Latency']) { [int]$p.Latency } else { 0 } $times += $lat } @@ -6297,8 +6938,11 @@ function fwallow { 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-PspHost ("Allow rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Green + try { + New-NetFirewallRule @splat -ErrorAction Stop | Out-Null + Write-PspHost ("Allow rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Green + } + catch { Write-Error "Could not add firewall rule '$Name': $($_.Exception.Message)" } } # Create an elevated firewall block rule after confirmation. @@ -6319,8 +6963,11 @@ function fwblock { 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-PspHost ("Block rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Yellow + try { + New-NetFirewallRule @splat -ErrorAction Stop | Out-Null + Write-PspHost ("Block rule added: {0} ({1} {2}{3})" -f $Name, $Direction, $Protocol, $portText) -ForegroundColor Yellow + } + catch { Write-Error "Could not add firewall rule '$Name': $($_.Exception.Message)" } } # Cybersec @@ -6457,8 +7104,8 @@ function defscan { if ($Path) { $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue if (-not $resolved) { Write-Error "Path not found: $Path"; return } - Write-PspHost ("Custom scan: {0}" -f $resolved.Path) -ForegroundColor Cyan - Start-MpScan -ScanType CustomScan -ScanPath $resolved.Path + Write-PspHost ("Custom scan: {0}" -f $resolved.ProviderPath) -ForegroundColor Cyan + Start-MpScan -ScanType CustomScan -ScanPath $resolved.ProviderPath } else { $scanType = if ($Mode -eq 'Full') { 'FullScan' } else { 'QuickScan' } @@ -6484,6 +7131,7 @@ function pwnd { 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) { @@ -6592,7 +7240,7 @@ function entropy { param([Parameter(Mandatory)][string]$Path) $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue if (-not $resolved) { Write-Error "File not found: $Path"; return } - $bytes = [System.IO.File]::ReadAllBytes($resolved.Path) + $bytes = [System.IO.File]::ReadAllBytes($resolved.ProviderPath) if ($bytes.Length -eq 0) { Write-PspHost 'Empty file.' -ForegroundColor Yellow; return } $counts = New-Object 'int[]' 256 foreach ($b in $bytes) { $counts[$b]++ } @@ -6614,8 +7262,7 @@ function entropy { # Developer -# 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. +# Serve a directory through Python or npx on loopback by default; -Bind 0.0.0.0 shares it on the LAN. function serve { <# .SYNOPSIS @@ -6633,26 +7280,32 @@ function serve { if (-not $resolved) { Write-Error "Path not found: $Path"; return } $oldTitle = Push-TabTitle "serve :$Port" try { + # Neither server imports or runs code from the served folder: python -I keeps it off sys.path, and npx starts in TEMP + # so a node_modules\.bin\http-server in that folder is never picked up. # 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 + $null = & python -I -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 } + & python -I -m http.server $Port --bind $Bind --directory $resolved.ProviderPath return } if (Get-Command npx -ErrorAction Ignore) { # 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 } + Push-Location -LiteralPath ([System.IO.Path]::GetTempPath()) + # Install scripts of http-server and its dependencies never run. + $savedIgnoreScripts = $env:npm_config_ignore_scripts + $env:npm_config_ignore_scripts = 'true' + try { & npx --yes $httpServer $resolved.ProviderPath -p $Port -a $Bind } + finally { + $env:npm_config_ignore_scripts = $savedIgnoreScripts + Pop-Location + } return } Write-Error 'Install python or node/npx first. E.g. winget install Python.Python.3.12' @@ -6703,16 +7356,37 @@ function gcof { } } -# Load exported or quoted .env values into the current session. +# Return $true for environment variable names that change which programs run, what they load, or where they connect. +function Test-PspSensitiveEnvName { + param([Parameter(Mandatory)][string]$Name) + $Name -match ('^(PATH|PATHEXT|PSModulePath|PSExecutionPolicyPreference|ComSpec|SystemRoot|SystemDrive|windir|TEMP|TMP|' + + 'APPDATA|LOCALAPPDATA|USERPROFILE|HOME|HOMEDRIVE|HOMEPATH|ProgramData|ProgramFiles|ProgramW6432|CommonProgramFiles|CommonProgramW6432|ALLUSERSPROFILE|PUBLIC|XDG_\w+|' + + 'GIT_\w+|SSH_ASKPASS|EDITOR|VISUAL|PAGER|BROWSER|BASH_ENV|\w*_PRELOAD|' + + 'NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|NODE_TLS_REJECT_UNAUTHORIZED|NPM_CONFIG_\w+|PIP_\w+|' + + 'PYTHONPATH|PYTHONHOME|PYTHONSTARTUP|PYTHONUSERBASE|PYTHONEXECUTABLE|PYTHONWARNINGS|PYTHONBREAKPOINT|PERL5OPT|PERL5LIB|PERLLIB|RUBYOPT|RUBYLIB|' + + 'CLASSPATH|DOTNET_(STARTUP_HOOKS|ADDITIONAL_DEPS|SHARED_STORE|ROOT\w*)|(COR|CORECLR)_(ENABLE_PROFILING|PROFILER\w*)|' + + '\w*_OPTS|\w*_OPTIONS|\w*_WRAPPER|\w*_CONFIG|\w*_CONFIG_(FILE|DIR)|(CARGO|RUSTUP|DOCKER|NUGET|YARN|BUNDLE|GEM|MSBUILD)_\w+|' + + 'KUBECONFIG|AWS_SHARED_CREDENTIALS_FILE|GOOGLE_APPLICATION_CREDENTIALS|OPENSSL_CONF|GOFLAGS|CC|CXX|LESSOPEN|LESSCLOSE|SSLKEYLOGFILE|' + + '(HTTPS?|ALL|NO|FTP)_PROXY|SSL_CERT_FILE|SSL_CERT_DIR|REQUESTS_CA_BUNDLE|CURL_CA_BUNDLE)$') +} + +# Load exported or quoted .env values into the current session; names that change how programs run need -AllowSensitive. function envload { <# .SYNOPSIS Load .env file. + .EXAMPLE + envload + Load .env from the current folder; PATH, NODE_OPTIONS, GIT_* and similar names are skipped with a warning. + .EXAMPLE + envload .\dev.env -AllowSensitive + Also set the names that change which programs run, what they load, or where they connect. #> - param([string]$Path = '.env') + param([string]$Path = '.env', [switch]$AllowSensitive) $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue if (-not $resolved) { Write-Error "File not found: $Path"; return } - $loaded = 0 + $loaded = [System.Collections.Generic.List[string]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() Get-Content -LiteralPath $resolved.ProviderPath -Encoding UTF8 | ForEach-Object { $line = $_.Trim() if (-not $line -or $line.StartsWith('#')) { return } @@ -6722,11 +7396,17 @@ function envload { 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() } + # A hostile .env could otherwise run code through PATH, NODE_OPTIONS, GIT_SSH_COMMAND and the like. + if (-not $AllowSensitive -and (Test-PspSensitiveEnvName -Name $k)) { $skipped.Add($k); return } Set-Item -LiteralPath "env:$k" -Value $v - $loaded++ + $loaded.Add($k) } } - Write-PspHost ("Loaded {0} variable(s) from {1}" -f $loaded, $resolved.Path) -ForegroundColor Green + if ($skipped.Count -gt 0) { + Write-Warning ("Skipped {0} variable(s) that change which programs run, what they load, or where they connect: {1}. Re-run with -AllowSensitive to load them." -f $skipped.Count, ($skipped -join ', ')) + } + $names = if ($loaded.Count -gt 0) { ': ' + ($loaded -join ', ') } else { '' } + Write-PspHost ("Loaded {0} variable(s) from {1}{2}" -f $loaded.Count, $resolved.Path, $names) -ForegroundColor Green } # Look up command examples through an installed tldr client or tldr-pages. @@ -6743,6 +7423,8 @@ function tldr { $url = "https://raw.githubusercontent.com/tldr-pages/tldr/main/pages/$platform/$cmd.md" $page = Invoke-RestMethod -Uri $url -TimeoutSec 10 -ErrorAction Stop -UseBasicParsing Write-PspHost $page -ForegroundColor White + # tldr-pages are CC BY 4.0, which asks for the source. + Write-PspHost "Source: tldr-pages (CC BY 4.0), $url" -ForegroundColor DarkGray return } catch { continue } @@ -6807,7 +7489,8 @@ function mkvenv { else { $null } if (-not $python) { Write-Error 'python not found on PATH (winget install Python.Python.3.12)'; return } Write-PspHost ("Creating venv at ./{0}..." -f $Name) -ForegroundColor Cyan - & $python -m venv $Name + # Isolated mode keeps a venv.py in the current folder from shadowing the standard library module. + & $python -I -m venv $Name if ($LASTEXITCODE -ne 0) { Write-Error 'venv creation failed'; return } $venvRoot = Resolve-Path -LiteralPath $Name $activate = Join-Path $venvRoot 'Scripts\Activate.ps1' @@ -6912,11 +7595,13 @@ function lint { return } Import-Module PSScriptAnalyzer -ErrorAction SilentlyContinue - $splat = @{ Path = $Path; Recurse = $true } + # Settings are always built here: without -Settings, PSScriptAnalyzer loads the analyzed folder's own settings file, + # and a CustomRulePath there would run that folder's code. + $settings = @{} switch ($Mode) { - 'Strict' { $splat.Severity = @('Error', 'Warning', 'Information') } + 'Strict' { $settings.Severity = @('Error', 'Warning', 'Information') } 'Security' { - $splat.IncludeRule = @( + $settings.IncludeRules = @( 'PSAvoidUsingPlainTextForPassword', 'PSAvoidUsingUsernameAndPasswordParams', 'PSUsePSCredentialType', @@ -6926,7 +7611,7 @@ function lint { ) } 'CI' { - $splat.ExcludeRule = @( + $settings.ExcludeRules = @( 'PSAvoidUsingWriteHost', 'PSAvoidUsingWMICmdlet', 'PSUseShouldProcessForStateChangingFunctions', @@ -6936,6 +7621,7 @@ function lint { ) } } + $splat = @{ Path = $Path; Recurse = $true; Settings = $settings } if ($Fix) { $splat.Fix = $true } $results = Invoke-ScriptAnalyzer @splat if (-not $results) { Write-PspHost 'No issues found.' -ForegroundColor Green; return } @@ -7017,7 +7703,7 @@ function Test-Profile { Write-PspHost (" Profile Exists: {0}" -f (Test-Path $PROFILE)) $userOverride = Join-Path (Split-Path $PROFILE) 'profile_user.ps1' Write-PspHost (" profile_user.ps1: {0}" -f $(if (Test-Path $userOverride) { 'present' } else { 'absent' })) - $cache = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' + $cache = Get-PspCacheDir Write-PspHost (" Cache Dir: {0}" -f $cache) if (Test-Path $cache) { $cacheFiles = Get-ChildItem $cache -File -ErrorAction SilentlyContinue @@ -7031,7 +7717,8 @@ function Test-Profile { Write-PspHost '' Write-PspHost 'Managed Tools:' -ForegroundColor Cyan foreach ($tool in $script:ProfileTools) { - $found = Get-Command $tool.Cmd -ErrorAction Ignore + # The same lookup as psp-doctor, which also finds the portable Oh My Posh build that is not on PATH. + $found = Get-ProfileToolExecutablePath -Tool $tool $status = if ($found) { 'OK' } else { 'MISSING' } $color = if ($found) { 'Green' } else { 'Yellow' } Write-PspHost (" {0,-14} {1}" -f $tool.Cmd, $status) -ForegroundColor $color @@ -7041,7 +7728,7 @@ function Test-Profile { 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 (" CI: {0}" -f ([bool]$env:CI -and $env:CI -ne 'false' -and $env:CI -ne '0')) Write-PspHost (" AI_AGENT: {0}" -f [bool]$env:AI_AGENT) } @@ -7160,6 +7847,7 @@ function Test-ProfileHistorySafeLine { '(?i)connectionstring' '(?i)credential' '(?i)bearer' + '(?i)asplaintext' # ConvertTo-SecureString -AsPlainText, which PSReadLine's own filter also catches '(?i)\b(VTCLI_APIKEY|VT_API_KEY)\b' # Scrub pwnd and jwtd invocations anywhere in a command line while excluding embedded verb names. '(?i)\bpwnd\s' @@ -7173,7 +7861,10 @@ function Test-ProfileHistorySafeLine { '\bASIA[0-9A-Z]{16}\b' # AWS temporary access key id '(?i)\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack token '\bAIza[0-9A-Za-z_\-]{35}\b' # Google API key - '\bsk-[A-Za-z0-9]{20,}' # OpenAI-style secret key + '\bsk-[A-Za-z0-9_-]{20,}' # OpenAI/Anthropic-style secret key (sk-proj-, sk-ant-, ...) + '\bglpat-[A-Za-z0-9_-]{20,}' # GitLab personal access token + '(?i)\bAccountKey=|\bSharedAccessSignature=|[?&]sig=[A-Za-z0-9%+/=]{20,}' # Azure storage key and SAS + '(?i)_(pass|pwd)\b' # *_PASS / *_PWD variables and arguments '(?i)\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe secret/restricted key (underscore style) '-----BEGIN[A-Z ]*PRIVATE KEY-----' # PEM private key material '(?i)://[^/\s:@]+:[^/\s:@]+@' # credentials embedded in a URL (user:pass@host) @@ -7199,8 +7890,139 @@ function Set-PspFeatureOverride { } } -# Configure PSReadLine from theme defaults and user color overrides. +# Import PSFzf on first chord use and let Set-PsFzfOption replace the loader bindings with PSFzf's handlers. +function Import-ProfileFzfModule { + if ($null -ne $script:ProfileFzfLoaded) { return $script:ProfileFzfLoaded } + $script:ProfileFzfLoaded = $false + try { + Import-Module PSFzf -Global -ErrorAction Stop + Set-PsFzfOption -PSReadlineChordProvider 'Ctrl+t' -PSReadlineChordReverseHistory 'Ctrl+r' + $script:ProfileFzfLoaded = $true + } + catch { Write-Verbose "PSFzf could not be imported: $_" } + return $script:ProfileFzfLoaded +} + +# Generate and load a cached native completion script on first Tab, then answer the pending request with it. +function Invoke-ProfileLazyCompleter { + param($WordToComplete, $CommandAst, $CursorPosition) + + $commandName = $null + try { $commandName = [System.IO.Path]::GetFileNameWithoutExtension([string]$CommandAst.GetCommandName()) } + catch { return } + if (-not $commandName -or -not $script:ProfileLazyCompleters) { return } + $spec = $script:ProfileLazyCompleters[$commandName] + if (-not $spec -or $spec.Attempted) { return } + $spec.Attempted = $true + + $exePath = Find-ProfileExecutable -Name $commandName + if (-not $exePath) { return } + $cachePath = Join-Path (Get-PspCacheDir) $spec.Cache + $ready = [System.IO.File]::Exists($cachePath) -and ([System.IO.FileInfo]::new($cachePath).Length -gt 0) + if (-not $ready) { + try { + $output = & $exePath @($spec.Args) 2>$null | Out-String + if ($output -and $output.Trim().Length -gt 0) { + [System.IO.File]::WriteAllText($cachePath, $output, [System.Text.UTF8Encoding]::new($false)) + $ready = $true + } + } + catch { $null = $_ } + } + if (-not $ready) { return } + + # A dynamic module keeps the script's helper functions reachable from the completer it registers. + try { + $module = New-Module -Name ('PSP.Completion.' + $commandName) -ScriptBlock { param($Path) . $Path } -ArgumentList $cachePath + } + catch { + # Corrupt cache; delete it so the next Tab regenerates it. + Remove-Item -LiteralPath $cachePath -Force -ErrorAction SilentlyContinue + $spec.Attempted = $false + return + } + # Cobra-generated scripts (kubectl, gh, docker) keep their completer in ${__CompleterBlock}. + $completer = & $module { param($Name) Get-Variable -Name ('__' + $Name + 'CompleterBlock') -ValueOnly -ErrorAction Ignore } $commandName + if ($completer -is [scriptblock]) { & $completer $WordToComplete $CommandAst $CursorPosition } +} + +# Return OMP theme JSON without the git data that costs an extra git process per prompt (upstream icon/URL, stash count), or $null when nothing changes. +function ConvertTo-ProfileFastOmpTheme { + param( + [Parameter(Mandatory)] + [string]$Json + ) + + # Each rewrite is kept only when the property disappears from the template, so unusual constructs are left untouched. + $rewrites = @( + @{ + # A stash block is removed only when it contains no nested if/range/with/end, so templates never end up unbalanced. + Pattern = '(?s)\{\{-?\s*if\s+(?:gt\s+\.StashCount\s+\d+|\.StashCount)\s*-?\}\}(?:(?!\{\{-?\s*(?:if|range|with|end)\b).)*\{\{-?\s*end\s*-?\}\}' + Property = '\.StashCount\b' + } + @{ + Pattern = '\{\{-?\s*(?:url\s+\.UpstreamIcon\s+\.UpstreamURL|\.UpstreamIcon)\s*-?\}\}' + Property = '\.Upstream(?:Icon|URL)\b' + } + ) + $theme = $Json | ConvertFrom-Json -ErrorAction Stop + $changed = $false + foreach ($block in @($theme.blocks)) { + foreach ($segment in @($block.segments)) { + if (-not $segment -or $segment.type -ne 'git') { continue } + foreach ($optionsName in 'options', 'properties') { + $optionsProperty = $segment.PSObject.Properties[$optionsName] + if ($optionsProperty -and $optionsProperty.Value -and $optionsProperty.Value.PSObject.Properties['fetch_upstream_icon'] -and $optionsProperty.Value.fetch_upstream_icon -eq $true) { + $optionsProperty.Value.fetch_upstream_icon = $false + $changed = $true + } + } + foreach ($templateName in 'template', 'templates') { + if (-not $segment.PSObject.Properties[$templateName] -or -not $segment.$templateName) { continue } + $templatesChanged = $false + $newTemplates = @(foreach ($template in @($segment.$templateName)) { + if ($template -is [string]) { + foreach ($rewrite in $rewrites) { + if ($template -match $rewrite.Property) { + $rewritten = $template -replace $rewrite.Pattern, '' + if ($rewritten -notmatch $rewrite.Property) { + $template = $rewritten + $templatesChanged = $true + } + } + } + } + $template + }) + if ($templatesChanged) { + $segment.$templateName = if ($templateName -eq 'template') { $newTemplates[0] } else { $newTemplates } + $changed = $true + } + } + } + } + if (-not $changed) { return $null } + return ($theme | ConvertTo-Json -Depth 32) +} + +# Route PSReadLine history through the profile's secret filter first, then through PSReadLine's own filter where it exists (2.2+). +function Register-ProfileHistoryHandler { + $script:ProfileHistoryDefaultFilter = $null -ne [Microsoft.PowerShell.PSConsoleReadLine].GetMethod('GetDefaultAddToHistoryOption') + Set-PSReadLineOption -AddToHistoryHandler { + param($line) + if (-not (Test-ProfileHistorySafeLine -Line $line)) { return $false } + if ($script:ProfileHistoryDefaultFilter) { return [Microsoft.PowerShell.PSConsoleReadLine]::GetDefaultAddToHistoryOption($line) } + return $true + } +} + +# Configure PSReadLine from theme defaults and user color overrides; theme.json and user-settings.json are parsed once here and reused at load. $_pspReadlineColors = $null +$_pspThemeConfig = $null +$_pspRlUser = $null +$_pspRlUserError = $null +try { $_pspRlUser = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') } +catch { $_pspRlUserError = $_ } try { $_pspThemeConfig = Read-JsonFile (Join-Path $cacheDir 'theme.json') if ($_pspThemeConfig) { @@ -7212,7 +8034,6 @@ try { } } # Merge user-settings.json.psreadline.colors on top so wizard/manual overrides win. - $_pspRlUser = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') if ($_pspRlUser) { # Apply feature toggles before initializing their load-time consumers. Set-PspFeatureOverride $_pspRlUser @@ -7304,54 +8125,39 @@ if ($script:PSP.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 -CommandType Application -ErrorAction Ignore)) { - if (-not $env:FZF_DEFAULT_COMMAND -and (Get-Command rg -CommandType Application -ErrorAction Ignore)) { + if ($script:PSP.Features.psfzf -and (Find-ProfileExecutable -Name 'fzf')) { + if (-not $env:FZF_DEFAULT_COMMAND -and (Find-ProfileExecutable -Name 'rg')) { $env:FZF_DEFAULT_COMMAND = 'rg --files --hidden --glob "!.git"' } if (-not $env:FZF_DEFAULT_OPTS) { $env:FZF_DEFAULT_OPTS = '--height=40% --layout=reverse' } - if (Get-Module -ListAvailable -Name PSFzf) { - Import-Module PSFzf -ErrorAction SilentlyContinue - if (Get-Module PSFzf) { - Set-PsFzfOption -PSReadlineChordProvider 'Ctrl+t' -PSReadlineChordReverseHistory 'Ctrl+r' + if (Test-ProfileModuleInstalled -Name 'PSFzf') { + # Importing PSFzf is slow, so these chords import it on first press and then hand over to PSFzf's own handlers. + $script:ProfileFzfLoaded = $null + Set-PSReadLineKeyHandler -Chord 'Ctrl+t' -BriefDescription 'FzfProviderSelect' -Description 'Run fzf for the current provider (imports PSFzf on first use)' -ScriptBlock { + if (Import-ProfileFzfModule) { Invoke-FzfPsReadlineHandlerProvider } + } + Set-PSReadLineKeyHandler -Chord 'Ctrl+r' -BriefDescription 'FzfReverseHistory' -Description 'Search history with fzf (imports PSFzf on first use)' -ScriptBlock { + param($key, $arg) + if (Import-ProfileFzfModule) { Invoke-FzfPsReadlineHandlerHistory } + else { [Microsoft.PowerShell.PSConsoleReadLine]::ReverseSearchHistory($key, $arg) } } } } - # Filter sensitive commands from history - Set-PSReadLineOption -AddToHistoryHandler { - param($line) - return (Test-ProfileHistorySafeLine -Line $line) - } + # Filter sensitive commands from history. + Register-ProfileHistoryHandler - # Cache native completion scripts to avoid launching each tool during shell startup. - $_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 ($_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 { - $_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 ($_pspNcReady) { - try { . $_pspNcCachePath } - catch { - # Corrupt cache; delete so next load regenerates. - Remove-Item -LiteralPath $_pspNcCachePath -Force -ErrorAction SilentlyContinue - } - } + # One stub completer generates, caches and loads each native completion script on first Tab instead of at every startup. + $script:ProfileLazyCompleters = @{ + kubectl = @{ Cache = 'kubectl-completion.ps1'; Args = @('completion', 'powershell'); Attempted = $false } + gh = @{ Cache = 'gh-completion.ps1'; Args = @('completion', '-s', 'powershell'); Attempted = $false } + docker = @{ Cache = 'docker-completion.ps1'; Args = @('completion', 'powershell'); Attempted = $false } + } + Register-ArgumentCompleter -Native -CommandName @($script:ProfileLazyCompleters.Keys) -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + Invoke-ProfileLazyCompleter -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition } # Prepend active environment, cloud, Kubernetes, and job indicators to the tab title. @@ -7413,7 +8219,7 @@ $_pspScriptblock = { Register-ArgumentCompleter -Native -CommandName git, npm, deno -ScriptBlock $_pspScriptblock # dotnet completion (only if dotnet is installed) -if (Get-Command dotnet -CommandType Application -ErrorAction Ignore) { +if (Find-ProfileExecutable -Name 'dotnet') { $_pspDotnetScriptblock = { param($wordToComplete, $commandAst, $cursorPosition) dotnet complete --position $cursorPosition $commandAst.ToString() | @@ -7428,26 +8234,23 @@ if (Get-Command dotnet -CommandType Application -ErrorAction Ignore) { if ($script:PSP.IsInteractive) { $_pspOmpExecutablePath = Get-OhMyPoshExecutablePath if ($_pspOmpExecutablePath) { - # Read the selected theme from cached config; user-settings.json can override the theme name. + # Read the selected theme from the theme.json parsed for PSReadLine; user-settings.json can override the theme name. $_pspThemeName = $null - try { - $_pspCfg = Read-JsonFile (Join-Path $cacheDir 'theme.json') - if ($_pspCfg -and $_pspCfg.theme -and $_pspCfg.theme.name) { $_pspThemeName = $_pspCfg.theme.name } + if ($_pspThemeConfig -and $_pspThemeConfig.theme -and $_pspThemeConfig.theme.name) { $_pspThemeName = $_pspThemeConfig.theme.name } + if ($_pspRlUser -and $_pspRlUser.theme -and $_pspRlUser.theme.name) { $_pspThemeName = $_pspRlUser.theme.name } + # The name becomes a file name in the cache folder, so path characters are rejected. + if ($_pspThemeName -and [string]$_pspThemeName -notmatch '^[A-Za-z0-9._-]+$') { + Write-Warning "Ignoring OMP theme with an invalid name: $_pspThemeName" + $_pspThemeName = $null } - 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 $_pspThemeName) { Write-Warning "No cached OMP theme is configured. Run Update-Profile or setup.ps1 to restore theme.json." } $_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. + if ($_pspLocalThemePath -and -not [System.IO.File]::Exists($_pspLocalThemePath)) { + # Move a theme file left in the profile folder into the cache. $_pspOldThemePath = Join-Path (Split-Path $PROFILE) "$_pspThemeName.omp.json" if (Test-Path -LiteralPath $_pspOldThemePath) { try { Move-Item -LiteralPath $_pspOldThemePath -Destination $_pspLocalThemePath -Force -ErrorAction Stop } @@ -7455,21 +8258,92 @@ if ($script:PSP.IsInteractive) { } } - if ($_pspLocalThemePath -and (Test-Path -LiteralPath $_pspLocalThemePath)) { + # Reuse the cached config choice and continuation prompt while the theme, the OMP binary, the profile version and the settings in the key + # are unchanged; a hit also skips revalidation, including the OMP version check. + $_pspSecondaryCachePath = [System.IO.Path]::Combine($cacheDir, 'omp-secondary-prompt.txt') + $_pspFastThemePath = [System.IO.Path]::Combine($cacheDir, 'omp-theme-fast.json') + # A theme that can run commands on every prompt loads only with theme.allowCommands set. + $_pspAllowThemeCommands = [bool](Get-PspProp (Get-PspProp $_pspRlUser 'theme') 'allowCommands') + $_pspOmpMinimum = [string](Get-PspComponentPins).OhMyPoshMinimum + $_pspOmpRefused = $false + $_pspThemeExists = $_pspLocalThemePath -and [System.IO.File]::Exists($_pspLocalThemePath) + $_pspSecondaryCacheKey = $null + $_pspSecondaryPrompt = $null + $_pspEffectiveConfigPath = $null + # A second pass reads the cache again after a binary that is too old was swapped for the managed portable build. + foreach ($_pspOmpPass in 1, 2) { + if ($_pspThemeExists) { + try { + $_pspThemeFile = [System.IO.FileInfo]::new($_pspLocalThemePath) + $_pspSecondaryCacheKey = '{0}|{1}|{2}|{3}|{4}|fastGitPrompt={5}|allowCommands={6}|profile={7}|ompMinimum={8}' -f $_pspLocalThemePath, $_pspThemeFile.LastWriteTimeUtc.Ticks, $_pspThemeFile.Length, $_pspOmpExecutablePath, [System.IO.File]::GetLastWriteTimeUtc($_pspOmpExecutablePath).Ticks, [bool]$script:PSP.Features.fastGitPrompt, $_pspAllowThemeCommands, $script:PSP.Version, $_pspOmpMinimum + if ([System.IO.File]::Exists($_pspSecondaryCachePath)) { + # Ordinal comparison avoids loading culture-aware collation during startup. + $_pspSecondaryCacheText = [System.IO.File]::ReadAllText($_pspSecondaryCachePath) + if ($_pspSecondaryCacheText.StartsWith($_pspSecondaryCacheKey + "`n", [System.StringComparison]::Ordinal)) { + # The line after the key is the config path to render with; the rest is the continuation prompt. + $_pspCachedBody = $_pspSecondaryCacheText.Substring($_pspSecondaryCacheKey.Length + 1) + $_pspPathEnd = $_pspCachedBody.IndexOf([char]10) + if ($_pspPathEnd -gt 0 -and [System.IO.File]::Exists($_pspCachedBody.Substring(0, $_pspPathEnd))) { + $_pspEffectiveConfigPath = $_pspCachedBody.Substring(0, $_pspPathEnd) + $_pspSecondaryPrompt = $_pspCachedBody.Substring($_pspPathEnd + 1) + } + } + } + } + catch { + $_pspSecondaryPrompt = $null + $_pspEffectiveConfigPath = $null + } + } + if (-not $_pspThemeExists -or $null -ne $_pspEffectiveConfigPath -or $_pspOmpPass -eq 2 -or (Test-PspOmpVersionSafe -Path $_pspOmpExecutablePath)) { break } + # Builds older than the minimum run template code taken from folder names (CVE-2026-73505), so they never render the prompt. + $_pspOmpPortablePath = Get-OhMyPoshPortablePath + if (-not [string]::Equals($_pspOmpPortablePath, $_pspOmpExecutablePath, [System.StringComparison]::OrdinalIgnoreCase) -and [System.IO.File]::Exists($_pspOmpPortablePath) -and (Test-PspOmpVersionSafe -Path $_pspOmpPortablePath)) { + $_pspOmpExecutablePath = $_pspOmpPortablePath + continue + } + Write-Warning ('Oh My Posh at ''{0}'' is older than {1} and runs code taken from folder names (CVE-2026-73505), so the plain prompt is used. Run Update-Tools to install the portable build, or update that install.' -f $_pspOmpExecutablePath, $_pspOmpMinimum) + $_pspOmpRefused = $true + break + } + + if ($null -eq $_pspEffectiveConfigPath -and $_pspThemeExists -and -not $_pspOmpRefused) { + $_pspThemeContent = $null try { - if ($null -eq (Read-JsonFile $_pspLocalThemePath)) { throw 'Theme file is empty' } + $_pspThemeContent = Get-Utf8FileText $_pspLocalThemePath + $_pspThemeObject = ConvertFrom-JsonC -Text $_pspThemeContent + if ($null -eq $_pspThemeObject) { throw 'Theme file is empty' } + $_pspThemeRisks = Get-PspOmpThemeRisk -Theme $_pspThemeObject + if (-not $_pspAllowThemeCommands -and $_pspThemeRisks.Count -gt 0) { + throw ("it can run commands on every prompt ({0}); set theme.allowCommands = true in user-settings.json to use it" -f ($_pspThemeRisks -join ', ')) + } + $_pspEffectiveConfigPath = $_pspLocalThemePath } catch { Write-Warning "Configured OMP theme is invalid at '$_pspLocalThemePath': $_" $_pspLocalThemePath = $null } + # fastGitPrompt renders from a tuned copy so the original theme file stays untouched. + if ($_pspEffectiveConfigPath -and $script:PSP.Features.fastGitPrompt) { + try { + $_pspFastTheme = ConvertTo-ProfileFastOmpTheme -Json $_pspThemeContent + if ($_pspFastTheme) { + [System.IO.File]::WriteAllText($_pspFastThemePath, $_pspFastTheme, [System.Text.UTF8Encoding]::new($false)) + $_pspEffectiveConfigPath = $_pspFastThemePath + } + } + catch { Write-Verbose "Keeping the original OMP theme; fast git tuning failed: $_" } + } + if ($_pspEffectiveConfigPath -ne $_pspFastThemePath -and [System.IO.File]::Exists($_pspFastThemePath)) { + Remove-Item -LiteralPath $_pspFastThemePath -Force -ErrorAction SilentlyContinue + } } - if ($_pspLocalThemePath -and (Test-Path -LiteralPath $_pspLocalThemePath)) { + if ($_pspLocalThemePath -and $_pspEffectiveConfigPath -and [System.IO.File]::Exists($_pspEffectiveConfigPath)) { try { - # Render with the validated local theme. + # Render with the validated (and possibly tuned) local theme. $script:OhMyPoshExecutablePath = $_pspOmpExecutablePath - $script:OhMyPoshConfigPath = $_pspLocalThemePath + $script:OhMyPoshConfigPath = $_pspEffectiveConfigPath $script:OhMyPoshLastHistoryId = $null $env:VIRTUAL_ENV_DISABLE_PROMPT = 1 $env:PYENV_VIRTUALENV_DISABLE_PROMPT = 1 @@ -7480,12 +8354,17 @@ if ($script:PSP.IsInteractive) { try { if (Get-Module PSReadLine -ErrorAction SilentlyContinue) { - $_pspSecondaryPrompt = Get-OhMyPoshPromptText ` - -Type 'secondary' ` - -ExecutablePath $script:OhMyPoshExecutablePath ` - -ConfigPath $script:OhMyPoshConfigPath + if ($null -eq $_pspSecondaryPrompt) { + $_pspSecondaryPrompt = @(Get-OhMyPoshPromptText ` + -Type 'secondary' ` + -ExecutablePath $script:OhMyPoshExecutablePath ` + -ConfigPath $script:OhMyPoshConfigPath) -join "`n" + if ($_pspSecondaryCacheKey -and -not [string]::IsNullOrWhiteSpace($_pspSecondaryPrompt)) { + [System.IO.File]::WriteAllText($_pspSecondaryCachePath, $_pspSecondaryCacheKey + "`n" + $_pspEffectiveConfigPath + "`n" + $_pspSecondaryPrompt, [System.Text.UTF8Encoding]::new($false)) + } + } if (-not [string]::IsNullOrWhiteSpace($_pspSecondaryPrompt)) { - Set-PSReadLineOption -ContinuationPrompt ($_pspSecondaryPrompt -join "`n") + Set-PSReadLineOption -ContinuationPrompt $_pspSecondaryPrompt } } } @@ -7527,6 +8406,23 @@ if ($script:PSP.IsInteractive) { } finally { $global:LASTEXITCODE = $originalLastExitCode + Exit-PspNoGcRegion + } + } + + # Render the first prompt while the rest of the profile loads; a fresh session has no history for the context to consume. + $script:OhMyPoshPromptPrefetch = $null + if (-not (Get-History -Count 1 -ErrorAction Ignore)) { + try { + Get-OhMyPoshPromptText ` + -Type 'primary' ` + -ExecutablePath $script:OhMyPoshExecutablePath ` + -ConfigPath $script:OhMyPoshConfigPath ` + -Prefetch + } + catch { + $script:OhMyPoshPromptPrefetch = $null + Write-Verbose "Failed to prefetch the first oh-my-posh prompt: $_" } } } @@ -7534,76 +8430,70 @@ if ($script:PSP.IsInteractive) { Write-Warning "Failed to initialize oh-my-posh with '$_pspLocalThemePath': $_" } } - elseif ($_pspThemeName) { + elseif ($_pspThemeName -and -not $_pspOmpRefused) { Write-Warning "Configured OMP theme '$_pspThemeName' was not found locally at '$_pspLocalThemePath'. Run Update-Profile or setup.ps1 to restore it." } } else { - Write-Warning "oh-my-posh not found. Install the MSI build or use: winget install JanDeDobbeleer.OhMyPosh" - } + Write-Warning "oh-my-posh not found. Run 'Update-Profile -InstallMissingTools' or setup.ps1 to install the portable build." + } +} + +# Write zoxide's PowerShell init script to the cache under a version header; $false when zoxide timed out or printed nothing. +function New-PspZoxideCache { + param([Parameter(Mandatory)][string]$ExecutablePath, [Parameter(Mandatory)][string]$CachePath) + $versionText = Invoke-WithTimeout -ScriptBlock { + param($exePath) + (& $exePath --version 2>$null | Out-String).Trim() + } -ArgumentList @($ExecutablePath) -TimeoutSec 5 + $initScript = Invoke-WithTimeout -ScriptBlock { + param($exePath) + (& $exePath init --cmd z powershell | Out-String) + } -ArgumentList @($ExecutablePath) -TimeoutSec 10 + if (-not $initScript) { return $false } + $version = if ($versionText) { $versionText } else { 'unknown' } + [System.IO.File]::WriteAllText($CachePath, ("# ZOXIDE_CACHE_VERSION: $version`n" + (@($initScript) -join "`n")), [System.Text.UTF8Encoding]::new($false)) + return $true } # zoxide initialization (interactive only, cached for fast startup) if ($script:PSP.IsInteractive) { - $_pspZoxideExecutablePath = Get-ExternalCommandPath -CommandName 'zoxide' + $_pspZoxideExecutablePath = Find-ProfileExecutable -Name 'zoxide' if ($_pspZoxideExecutablePath) { - $_pspZoxideCachePath = Join-Path $cacheDir "zoxide-init.ps1" - # Reuse a non-empty zoxide cache and time out regeneration. + $_pspZoxideCachePath = [System.IO.Path]::Combine($cacheDir, 'zoxide-init.ps1') + # Reuse a zoxide cache whose first line is the version header (an empty file has none) and time out regeneration. $_pspCacheValid = $false - if (Test-Path $_pspZoxideCachePath) { + if ([System.IO.File]::Exists($_pspZoxideCachePath)) { try { - $_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 } - } + $_pspZoxideReader = [System.IO.StreamReader]::new($_pspZoxideCachePath) + try { $_pspCacheContent = $_pspZoxideReader.ReadLine() } + finally { $_pspZoxideReader.Dispose() } + if ($_pspCacheContent -match '^# ZOXIDE_CACHE_VERSION: .+') { $_pspCacheValid = $true } } catch { Write-Verbose "Zoxide cache read failed (will regenerate)." } if (-not $_pspCacheValid) { Remove-Item $_pspZoxideCachePath -Force -ErrorAction SilentlyContinue } } + $_pspZoxideGenerated = $false if (-not $_pspCacheValid) { - $_pspZoxideVersionResult = Invoke-WithTimeout -ScriptBlock { - param($exePath) - (& $exePath --version 2>$null | Out-String).Trim() - } -ArgumentList @($_pspZoxideExecutablePath) -TimeoutSec 5 - $_pspZoxideVersion = if ($_pspZoxideVersionResult) { $_pspZoxideVersionResult } else { 'unknown' } - $_pspInitScript = Invoke-WithTimeout -ScriptBlock { - param($exePath) - (& $exePath init --cmd z powershell | Out-String) - } -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 { + $_pspZoxideGenerated = $true + if (-not (New-PspZoxideCache -ExecutablePath $_pspZoxideExecutablePath -CachePath $_pspZoxideCachePath)) { Write-Warning "zoxide init timed out or produced no output. Cache not written." } } - try { - . $_pspZoxideCachePath - } - catch { - Remove-Item $_pspZoxideCachePath -Force -ErrorAction SilentlyContinue - $_pspZoxideVersionResult = Invoke-WithTimeout -ScriptBlock { - param($exePath) - (& $exePath --version 2>$null | Out-String).Trim() - } -ArgumentList @($_pspZoxideExecutablePath) -TimeoutSec 5 - $_pspZoxideVersion = if ($_pspZoxideVersionResult) { $_pspZoxideVersionResult } else { 'unknown' } - $_pspInitScript = Invoke-WithTimeout -ScriptBlock { - param($exePath) - (& $exePath init --cmd z powershell | Out-String) - } -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: $_" } + if ([System.IO.File]::Exists($_pspZoxideCachePath)) { + try { + . $_pspZoxideCachePath } - else { - Write-Warning "Failed to initialize zoxide: $_" + catch { + # A cache left by an older zoxide is generated again once; one generated during this start is only reported. + $_pspZoxideError = $_ + Remove-Item -LiteralPath $_pspZoxideCachePath -Force -ErrorAction SilentlyContinue + if (-not $_pspZoxideGenerated -and (New-PspZoxideCache -ExecutablePath $_pspZoxideExecutablePath -CachePath $_pspZoxideCachePath)) { + try { . $_pspZoxideCachePath } catch { Write-Warning "Failed to initialize zoxide: $_" } + } + else { + Write-Warning "Failed to initialize zoxide: $_pspZoxideError" + } } } } @@ -7634,7 +8524,7 @@ function Get-ProfileCommand { $cmds | Sort-Object Category, Name } -# Walk through command categories interactively on first run. +# Walk through the command categories interactively. function Start-ProfileTour { <# .SYNOPSIS @@ -7914,6 +8804,8 @@ function Set-TerminalBackground { Write-Warning "Live apply to WT settings.json failed for $wtSettingsPath`: $($_.Exception.Message)" } } + # -WhatIf has only listed the writes, so there is nothing to confirm. + if ($WhatIfPreference) { return } 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'), @@ -7991,13 +8883,12 @@ function Save-TrustedDirectories { return $true } -# Help Function (PS5-compatible - $PSStyle only exists in PS7.2+) +# Print the command reference, in plain text when NO_COLOR is set (https://no-color.org) or $PSStyle is missing (before PowerShell 7.2). function Show-Help { <# .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 @@ -8016,10 +8907,10 @@ ${g}edit${r} - Open file in preferred editor. ${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}Update-Tools${r} - Update winget-managed tools and the portable Oh My Posh build (replaces an MSIX install); other direct 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}. reload${r} - Reload the PowerShell profile (dot-source it so functions refresh too). ${g}Clear-ProfileCache${r} - Reset profile caches plus OMP internal 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. @@ -8102,7 +8993,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 running containers. ${g}dprune${r} - System prune. Both ask first. +${g}dstop${r} [names] - Stop named containers, or all running ones. ${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. @@ -8145,7 +9036,7 @@ ${c}Developer+${r} ${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. +${g}envload${r} [path] [-AllowSensitive] - Load .env file into current session (PATH, NODE_OPTIONS, GIT_* and similar are skipped unless -AllowSensitive). ${g}tldr${r} - Quick command-example lookup (tldr-pages). ${g}repeat${r} { cmd } [-UntilSuccess] [-DelaySeconds n] - Repeat a scriptblock. ${g}mkvenv${r} [name] - Create and activate a Python venv. @@ -8202,11 +9093,11 @@ Extend the profile without forking: $script:_seedCommands = @( @{ 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 = 'Update-Tools'; Category = 'Profile'; Synopsis = 'Upgrade winget-managed tools and the portable Oh My Posh build' } @{ Name = 'Edit-Profile'; Category = 'Profile'; Synopsis = 'Open profile in preferred editor' } @{ Name = 'Show-Help'; Category = 'Profile'; Synopsis = 'Show this help message' } - @{ Name = 'reload'; Category = 'Profile'; Synopsis = 'Reload the profile in-place' } - @{ Name = 'Clear-ProfileCache'; Category = 'Profile'; Synopsis = 'Reset caches except user settings' } + @{ Name = 'reload'; Category = 'Profile'; Synopsis = "Reload the profile; run as '. reload' to refresh functions too" } + @{ Name = 'Clear-ProfileCache'; Category = 'Profile'; Synopsis = 'Reset caches; settings and theme files are kept' } @{ Name = 'Clear-Cache'; Category = 'Profile'; Synopsis = 'Clear user/system temp caches' } @{ Name = 'Uninstall-Profile'; Category = 'Profile'; Synopsis = 'Remove profile, caches, WT changes' } @{ Name = 'Invoke-ProfileWizard'; Category = 'Profile'; Synopsis = 'Re-run install wizard (alias: Reconfigure-Profile)' } @@ -8301,15 +9192,15 @@ $script:_seedCommands = @( @{ Name = 'killport'; Category = 'Developer'; Synopsis = 'Kill process on TCP port' } @{ Name = 'Stop-ListeningPort'; Category = 'Developer'; Synopsis = 'Interactive picker for listening ports (alias: killports)' } @{ Name = 'Find-FileLocker'; Category = 'Sysadmin'; Synopsis = 'Show processes holding a file/folder lock' } - @{ Name = 'Stop-StuckProcess'; Category = 'Sysadmin'; Synopsis = 'Escalating kill: Stop-Process -> taskkill /F -> /F /T' } + @{ Name = 'Stop-StuckProcess'; Category = 'Sysadmin'; Synopsis = 'Escalating kill: Stop-Process, then taskkill /F (/T with -Tree)' } @{ Name = 'Remove-LockedItem'; Category = 'Sysadmin'; Synopsis = 'Find lockers, kill, and delete (combo)' } @{ Name = 'http'; Category = 'Developer'; Synopsis = 'HTTP requests with auto JSON format' } @{ Name = 'prettyjson'; Category = 'Developer'; Synopsis = 'Pretty-print JSON' } - @{ Name = 'hb'; Category = 'Developer'; Synopsis = 'Upload to hastebin' } + @{ Name = 'hb'; Category = 'Developer'; Synopsis = 'Upload a file to a public paste (bin.christitus.com)' } @{ Name = 'timer'; Category = 'Developer'; Synopsis = 'Measure execution time' } @{ Name = 'watch'; Category = 'Developer'; Synopsis = 'Repeat command every n seconds' } @{ Name = 'serve'; Category = 'Developer'; Synopsis = 'One-line HTTP server' } - @{ Name = 'envload'; Category = 'Developer'; Synopsis = 'Load .env file' } + @{ Name = 'envload'; Category = 'Developer'; Synopsis = 'Load .env file (sensitive names need -AllowSensitive)' } @{ Name = 'tldr'; Category = 'Developer'; Synopsis = 'Quick command examples' } @{ Name = 'repeat'; Category = 'Developer'; Synopsis = 'Repeat scriptblock N times' } @{ Name = 'mkvenv'; Category = 'Developer'; Synopsis = 'Create + activate Python venv' } @@ -8341,7 +9232,7 @@ $script:_seedCommands = @( @{ Name = 'dimg'; Category = 'Docker'; Synopsis = 'List images' } @{ Name = 'dlogs'; Category = 'Docker'; Synopsis = 'Follow container logs' } @{ Name = 'dex'; Category = 'Docker'; Synopsis = 'Exec into container' } - @{ Name = 'dstop'; Category = 'Docker'; Synopsis = 'Stop all running containers' } + @{ Name = 'dstop'; Category = 'Docker'; Synopsis = 'Stop named containers, or all running ones' } @{ Name = 'dprune'; Category = 'Docker'; Synopsis = 'docker system prune' } @{ Name = 'cpy'; Category = 'Clipboard'; Synopsis = 'Copy text to clipboard' } @{ Name = 'pst'; Category = 'Clipboard'; Synopsis = 'Paste from clipboard' } @@ -8360,12 +9251,11 @@ foreach ($_pspEntry in $script:_seedCommands) { } Remove-Variable -Name _seedCommands -Scope Script -ErrorAction SilentlyContinue -# Load user settings before PowerShell overrides and plugins. -$script:UserSettings = $null -try { $script:UserSettings = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') } -catch { Write-Warning "user-settings.json unreadable: $($_.Exception.Message)" } +# Load user settings before PowerShell overrides and plugins, reusing the parse from the PSReadLine setup. +$script:UserSettings = $_pspRlUser +if ($_pspRlUserError) { Write-Warning "user-settings.json unreadable: $($_pspRlUserError.Exception.Message)" } if ($script:UserSettings) { - # Authoritative feature apply (also covers the case where the early PSReadLine-block apply was skipped/failed). + # Apply feature toggles again in case the PSReadLine block skipped them. Set-PspFeatureOverride $script:UserSettings # 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 @@ -8395,8 +9285,8 @@ if ($script:UserSettings) { } catch { Write-Warning "commandOverride '$_pspOvName' failed: $($_.Exception.Message)" } } - # Visible notice every interactive load so JSON-sourced code execution is not invisible. - if ($_pspAppliedOverrides.Count -gt 0 -and $script:PSP.IsInteractive -and $script:PSP.Features.startupMessage) { + # Shown on every interactive load, also with the startup message off, because the opt-in sits in the same file as the overrides. + if ($_pspAppliedOverrides.Count -gt 0 -and $script:PSP.IsInteractive) { Write-PspHost ("commandOverrides active ({0}): {1}" -f $_pspAppliedOverrides.Count, ($_pspAppliedOverrides -join ', ')) -ForegroundColor Yellow } } @@ -8404,23 +9294,30 @@ if ($script:UserSettings) { } # Load persistent PowerShell overrides after JSON settings and before plugins. -$_pspUserProfile = Join-Path (Split-Path $PROFILE) "profile_user.ps1" -if (Test-Path -LiteralPath $_pspUserProfile) { +$_pspUserProfile = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($PROFILE), 'profile_user.ps1') +if ([System.IO.File]::Exists($_pspUserProfile)) { try { . $_pspUserProfile } catch { Write-Warning "Failed to load profile_user.ps1: $_" } } -# Dot-source plugins with script scope while isolating errors per file. -$_pspPluginDir = Join-Path $cacheDir 'plugins' -if (-not (Test-Path -LiteralPath $_pspPluginDir)) { +# Dot-source plugins in name order with script scope while isolating errors per file. +$_pspPluginDir = [System.IO.Path]::Combine($cacheDir, 'plugins') +if (-not [System.IO.Directory]::Exists($_pspPluginDir)) { try { New-Item -ItemType Directory -Path $_pspPluginDir -Force | Out-Null } catch { $null = $_ } } -if (Test-Path -LiteralPath $_pspPluginDir) { - foreach ($_pspPlugin in (Get-ChildItem -LiteralPath $_pspPluginDir -Filter *.ps1 -File -ErrorAction SilentlyContinue | Sort-Object Name)) { - try { . $_pspPlugin.FullName } +if ([System.IO.Directory]::Exists($_pspPluginDir)) { + $_pspPluginFiles = @() + try { $_pspPluginFiles = [System.IO.Directory]::GetFiles($_pspPluginDir, '*.ps1') } catch { $null = $_ } + # Culture-aware, case-insensitive ordering matches Sort-Object Name. + if ($_pspPluginFiles.Count -gt 1) { [System.Array]::Sort($_pspPluginFiles, [System.StringComparer]::CurrentCultureIgnoreCase) } + foreach ($_pspPlugin in $_pspPluginFiles) { + # The Windows *.ps1 pattern also matches .ps1xml; hidden and system files stay skipped, as with Get-ChildItem. + if (-not $_pspPlugin.EndsWith('.ps1', [System.StringComparison]::OrdinalIgnoreCase)) { continue } + if (([System.IO.File]::GetAttributes($_pspPlugin) -band ([System.IO.FileAttributes]::Hidden -bor [System.IO.FileAttributes]::System)) -ne 0) { continue } + try { . $_pspPlugin } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } - Write-Warning "Plugin '$($_pspPlugin.Name)' failed to load: $($_.Exception.Message)" + Write-Warning "Plugin '$([System.IO.Path]::GetFileName($_pspPlugin))' failed to load: $($_.Exception.Message)" } } } @@ -8448,7 +9345,7 @@ if ($script:PSP.IsInteractive -and $script:PSP.Features.updateCheck) { 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 ($_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 + Write-PspHost ("Update available: main is at {0}... (applied {1}...). Run Update-Profile; it prints the bundle hash to pin with -ExpectedSha256 (or use -SkipHashCheck)." -f $_pspUcLatest.Substring(0, 7), $_pspUcStored.Substring(0, 7)) -ForegroundColor Yellow } # Record the check time only after a successful API response. Write-Utf8FileAtomic -Path $_pspUcStampFile -Content (Get-Date).ToString('o') @@ -8461,6 +9358,9 @@ if ($script:PSP.IsInteractive -and $script:PSP.Features.updateCheck) { # Fire OnProfileLoad hooks (user/plugin extensions). Invoke-ProfileHook -EventName 'OnProfileLoad' +# A prompt that profile_user.ps1 or a plugin defined does not end the startup garbage collection pause, so it ends here. +if ($script:PspNoGcRegion -and -not ([string]$Function:prompt).Contains('Exit-PspNoGcRegion')) { Exit-PspNoGcRegion } + # Startup complete - show load time $_pspStopwatch.Stop() $script:PSP.LoadTimeMs = $_pspStopwatch.ElapsedMilliseconds diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index cc63c11..4bfd589 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -5,7 +5,7 @@ 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. + # Get-SystemBootTime uses Get-WmiObject on Windows PowerShell 5.1. 'PSAvoidUsingWMICmdlet' # Short Unix-style helpers (touch, mkcd, export) change state by design; destructive commands support -WhatIf. 'PSUseShouldProcessForStateChangingFunctions' diff --git a/README.md b/README.md index 8f66967..9a60579 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 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. +> **Stability.** The command surface, install flow, and extension points are settled; new work stays additive where it can and is described in each [release](https://github.com/26zl/PowerShellPerfect/releases)'s notes. 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 @@ -30,7 +30,7 @@ With `-Ref vX.Y.Z`, setup resolves the tag to its commit, downloads the profile, ```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 +gh attestation verify psp-release\setup.ps1 --repo 26zl/PowerShellPerfect --source-ref refs/tags/v1.0.0 --signer-workflow 26zl/PowerShellPerfect/.github/workflows/release.yml # repeat for the other files cd psp-release; powershell -ExecutionPolicy Bypass -File .\setup.ps1 # installs from the verified files ``` @@ -42,7 +42,7 @@ $setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" & ([scriptblock]::Create($setup)) -Ref -ExpectedSha256 '' # installs exactly that commit ``` -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. +Run setup as a script block, as shown, or as a file; `irm ... | iex` is not supported. As a script block it sets `$LASTEXITCODE` instead of exiting and removes the variables it created in your session, except after a successful install in an interactive, non-elevated window: setup then asks you to press Enter, opens a new terminal and closes the one it ran in. 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). @@ -61,7 +61,7 @@ The **install wizard runs by default** in an interactive window (pass `-SkipWiza | **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** | 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. | +| **Network** | Setup fetches the profile bundle from GitHub, the Oh My Posh theme, the portable Oh My Posh build and the Nerd Font at pinned versions, and the other CLI tools through winget. `Update-Profile` fetches the bundle and the theme; it installs missing tools (the portable Oh My Posh build, winget packages, PSFzf) only with `-InstallMissingTools` and never installs fonts. 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. | ## What setup changes @@ -69,11 +69,15 @@ The **install wizard runs by default** in an interactive window (pass `-SkipWiza | 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` | +| Starter `profile_user.ps1` for your own overrides (every line commented out), created only when missing | `Documents\PowerShell`, `Documents\WindowsPowerShell` | `Uninstall-Profile -RemoveUserData` | +| Interactive runs: the editor you pick, as a `$script:EditorPriority` line in each `profile_user.ps1` (the previous file is kept as `profile_user.ps1.bak`) | `Documents\PowerShell`, `Documents\WindowsPowerShell` | `Uninstall-Profile -RemoveUserData` (removes `profile_user.ps1` and the `.bak`) | +| Caches (each run clears the generated zoxide and Oh My Posh caches so the next load rebuilds them), `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, the keybindings you list in `user-settings.json` (none are shipped; a `Ctrl+A` binding left by an earlier version is removed again), 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 | +| Oh My Posh portable build, pinned release, SHA-256 and Authenticode signer checked (winget is the fallback when the download fails; an existing direct or MSI install is left alone) | `%LOCALAPPDATA%\Programs\oh-my-posh\bin` | `Uninstall-Profile -RemoveTools`, which also removes a winget or MSI install of Oh My Posh, even one made before setup | +| winget packages: eza, zoxide, fzf, bat, ripgrep (winget accepts the source and package agreements for you) | winget | `Uninstall-Profile -RemoveTools`, which removes these packages even when they were installed before setup | | 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: the editor you pick, when it is not installed; setup then installs it with winget (this accepts its license) | winget | not reverted (`Uninstall-Profile` does not remove it) | | 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) | @@ -88,10 +92,10 @@ Inspired by [powerlevel10k](https://github.com/romkatv/powerlevel10k)'s `p10k co # Resume a half-finished wizard (state in %LOCALAPPDATA%\PowerShellProfile\wizard-state.json): .\setup.ps1 -Resume -# Re-run the wizard any time after install (setup.ps1 of a release is checked against its SHA256SUMS.txt; runs in this window): +# Re-run the wizard any time after install (setup.ps1 of a release is checked against its SHA256SUMS.txt; runs in a child PowerShell 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 '' +# From a branch or commit, pin setup.ps1 and then the bundle to the hashes the runs print: +Reconfigure-Profile -Ref -ExpectedSha256 '' -BundleExpectedSha256 '' ``` **Steps** (all accept Enter to keep the default / skip): @@ -104,7 +108,7 @@ Reconfigure-Profile -Ref -ExpectedSha256 '' 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. +9. **Editor preference**: Visual Studio Code, Neovim, Vim, Microsoft Edit, Sublime Text, Notepad++, or Notepad. If the one you pick is not installed, you are asked whether setup should install it with winget, which accepts its license; the install runs after the summary. 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**: @@ -132,7 +136,7 @@ Inspired by [ChrisTitusTech/powershell-profile](https://github.com/ChrisTitusTec ## Install (alternatives) -> **Recommended for Oh My Posh:** Install the x64 MSI from the [releases](https://github.com/JanDeDobbeleer/oh-my-posh/releases) page (see [Oh My Posh](https://github.com/JanDeDobbeleer/oh-my-posh)) instead of `winget`/Store; this profile preserves a direct install and avoids the WindowsApps path. If you already have the MSI install, setup leaves it as is. +> **Oh My Posh:** setup installs the portable release build of [Oh My Posh](https://github.com/JanDeDobbeleer/oh-my-posh) (pinned version, SHA-256 and signer checked) to `%LOCALAPPDATA%\Programs\oh-my-posh\bin`; the profile adds that folder to `PATH` for each session, so `oh-my-posh` also runs by name. The `winget`/Store MSIX build adds about 30 ms to every prompt, so `Update-Tools` replaces it with the portable build; a direct or MSI install you made yourself is left as is. ### Manual Setup (local clone) @@ -152,22 +156,31 @@ 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. +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, `2` when the wizard was cancelled (nothing is 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 -> PowerShell script, ransomware included, write to your protected folders. +> PowerShell script, ransomware included, write to your protected folders. Changing the list needs +> an elevated window, while setup runs in a normal one: > > ```powershell -> $ps = (Get-Process -Id $PID).Path # also right for the Store build, whose path changes per version +> # In the normal window you will run setup from: print the PowerShell to allow +> (Get-Process -Id $PID).Path # also right for the Store build, whose path changes per version +> +> # In an elevated window: allow it +> $ps = '' > Add-MpPreference -ControlledFolderAccessAllowedApplications $ps +> +> # In the normal window: install > .\setup.ps1 +> +> # In the elevated window: take it out again and check it is gone > Remove-MpPreference -ControlledFolderAccessAllowedApplications $ps -> (Get-MpPreference).ControlledFolderAccessAllowedApplications # check it is gone +> (Get-MpPreference).ControlledFolderAccessAllowedApplications > ``` > -> Later profile and module updates write to the same folder and need the same two lines around them. +> Later profile and module updates write to the same folder and need the same allow and remove steps around them. ## Updates @@ -176,7 +189,7 @@ Update-Profile -Ref v1.1.0 # Install a release, checked against its SH 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-Tools # Update winget-managed tools and the portable Oh My Posh build; tools installed another way are left alone ``` `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. @@ -186,7 +199,7 @@ Update-Tools # Update winget-managed tools; direct/MSI O ```powershell 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 -RemoveTools # Also uninstall the managed CLI tools, the portable Oh My Posh build and PSFzf; winget packages and an Oh My Posh MSI go even if they were installed before setup Uninstall-Profile -All # Remove everything including tools, fonts, and user data Uninstall-Profile -All -HardResetWindowsTerminal # Same as -All, but also delete WT settings.json so WT recreates factory defaults Uninstall-Profile -All -Force # Also uninstall PSFzf/managed tools when CI or agent guards are active @@ -201,14 +214,15 @@ A plain `Uninstall-Profile` keeps `user-settings.json`, `profile_user.ps1`, `plu Four extension points survive updates. From simplest to most powerful: - **`user-settings.json`** (`%LOCALAPPDATA%\PowerShellProfile\`) - JSON overrides (comments and trailing commas allowed). Keys: - - `theme`, `windowsTerminal`, `defaults`, `keybindings` - terminal and OMP theme + - `theme`, `windowsTerminal`, `defaults`, `keybindings` - terminal and OMP theme; `keybindings` are the only Windows Terminal bindings the profile sets, and one you remove here is removed from `settings.json` on the next update + - `theme.allowCommands` - set to `true` to use a theme that can run commands on every prompt: `command` segments, template functions such as `cmd`, `readFile` or `getHostByName`, or `extends` (which pulls in a config the profile does not check). Without it such a theme is refused at download and at load (none of the official themes uses any of these). - `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) + - `features` - toggle heavy/optional behavior: `psfzf`, `predictions`, `startupMessage`, `perDirProfiles`, `fastGitPrompt` (renders git-heavy themes from a copy without the upstream and stash lookups, one git process fewer per prompt) (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` 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 +- **`plugins/*.ps1`** (`%LOCALAPPDATA%\PowerShellProfile\plugins\`) - drop-in plugins, loaded in name order (hidden files are skipped). Each file is auto-loaded; errors are isolated per plugin - **`.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 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. @@ -236,7 +250,6 @@ Default fills the whole tab with low opacity (typical backdrop). For a small cor | `Ctrl+Left` / `Ctrl+Right` | Jump word backwards / forwards | | `Ctrl+Z` / `Ctrl+Y` | Undo / Redo | | `Alt+V` | Smart paste (inserts clipboard without executing) | -| `Ctrl+A` | Select entire terminal buffer (Windows Terminal) | ## Commands @@ -250,9 +263,9 @@ Run `Show-Help` in your terminal for a colored version of this list. | `Edit-Profile` / `ep` | Open profile in preferred editor | | `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 | +| `Update-Tools` | Update winget-managed tools and the portable Oh My Posh build (an MSIX install is replaced); tools installed another way (scoop, choco, by hand, a direct/MSI Oh My Posh) are left alone and reported as preserved | | `Invoke-ProfileWizard` / `Reconfigure-Profile` | Re-run the install wizard to pick a new theme / scheme / font / feature set | -| `reload` | Reload the PowerShell profile | +| `reload` | Reload the PowerShell profile; run it as `. reload` so new and changed functions reach the session | | `Show-Help` | Show help in terminal | | `Uninstall-Profile` | Remove profile, caches, and the WT changes it made (`-All` for everything) | @@ -327,7 +340,7 @@ Run `Show-Help` in your terminal for a colored version of this list. | `wifipass [ssid] [-Reveal]` | List saved WiFi profiles (passwords masked unless `-Reveal`) | | `hosts` | Open hosts file in elevated editor | | `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) | +| `Clear-ProfileCache` | Reset profile caches plus OMP internal caches (settings, state and theme files are kept) | | `duration` | Show elapsed time of the last executed command | | `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. | @@ -343,7 +356,7 @@ Run `Show-Help` in your terminal for a colored version of this list. | `mtr ` | Traceroute with per-hop ping stats | | `fwallow` / `fwblock [-Port n]` | Quick Windows Firewall rule (needs admin; supports `-WhatIf` / `-Confirm`) | | `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 | +| `Stop-StuckProcess [-Tree]` | Escalating kill: `Stop-Process -Force`, then `taskkill /F` (`/T` with `-Tree`) 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 @@ -352,7 +365,7 @@ Run `Show-Help` in your terminal for a colored version of this list. | --- | --- | | `hash [algo]` | File hash (default SHA256) | | `checksum ` | Verify file hash | -| `genpass [length]` | Random password (default 20), copies to clipboard | +| `genpass [length]` | Random password (default 20), copied to the clipboard and kept out of Windows clipboard history and cloud sync | | `b64` / `b64d ` | Base64 encode / decode | | `jwtd ` | Decode JWT header and payload | | `uuid` | Generate random UUID (copies to clipboard) | @@ -383,7 +396,7 @@ Run `Show-Help` in your terminal for a colored version of this list. | `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 | +| `envload [path] [-AllowSensitive]` | Load .env file into current session; names that change which programs run or where they connect (`PATH`, `NODE_OPTIONS`, `GIT_*`, proxies, ...) are skipped unless `-AllowSensitive` | | `tldr ` | Quick command-example lookup (tldr-pages) | | `repeat { cmd } [-UntilSuccess]` | Repeat a scriptblock | | `mkvenv [name]` | Create and activate a Python venv | @@ -423,7 +436,7 @@ 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 running containers (asks first) | +| `dstop [names]` | Stop the named containers, or all running ones (asks first) | | `dprune` | System prune (asks first) | ### SSH & Remote @@ -469,7 +482,8 @@ PowerShellPerfect bundles a **prompt** (via Oh My Posh), a **command suite**, an | p10k-style install wizard | ✅ | — | — | — | | 145+ commands (Unix-style + git/net/security/sysadmin) | ✅ | partial | — | — | | Prompt theming | ✅ (via Oh My Posh) | ✅ (via Oh My Posh) | ✅ (engine) | ✅ (engine) | -| Hash-verified self-update + full uninstall | ✅ | ✅ | n/a | n/a | +| Self-update checked against published hashes | ✅ | — (a hash compare only detects changes) | n/a | n/a | +| Full uninstall | ✅ | — | n/a | n/a | | PS 5.1 + PS 7 (guarded forks) | ✅ | ✅ | ✅ | ✅ (cross-shell) | | Secret-scrubbed history | ✅ | — | — | — | | Customization surfaces | `user-settings.json` + `profile_user.ps1` + `plugins/` + `.psprc.ps1` | profile edits | themes | `starship.toml` | @@ -490,25 +504,28 @@ PowerShellPerfect bundles a **prompt** (via Oh My Posh), a **command suite**, an | 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 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`. | +| `oh-my-posh` not found right after install | Open a new terminal, or run `. reload` in the current one; confirm with `psp-doctor`. | +| Warning that Oh My Posh at `` is older than 29.35.1 (CVE-2026-73505) | Builds before 29.35.1 run template code taken from folder names, so the prompt never uses them. Run `Update-Tools` to install the portable build, which the prompt then uses, or update or remove that install. | | 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. | +| Prompt feels slow | Run `psp-doctor`: a WARN on Oh My Posh means the MSIX build is in use (about 30 ms per prompt for package activation), and `Update-Tools` switches to the portable build. In git repositories each prompt runs `git status`; keep `features.fastGitPrompt` on so themes do not add git calls for upstream icons and stash counts. | +| A theme is refused because it "can run commands on every prompt" | The message names what was found (a `command` segment, a template function such as `cmd`, or `extends`). Read the theme, then set `theme.allowCommands` to `true` in `user-settings.json` if you trust it. | | 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. +Everything in `tests/` is tracked. Run the suites from a normal (non-elevated) shell: `unit.ps1`, `test.ps1`, `ci-functional.ps1`, `rawhunt.ps1` and the test steps of `locallab.ps1` refuse to run elevated unless you pass `-AllowElevated`, which is meant for disposable VMs and CI runners. `lint.ps1` and `smoke.ps1` do not check. | 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/lint.ps1` | `pwsh -NoProfile -File tests/lint.ps1` | PSScriptAnalyzer 1.25.0 with `PSScriptAnalyzerSettings.psd1`, as in CI. Installs that version for the current user when it is missing; `-NoInstall` fails instead | | `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 | +| `tests/locallab.ps1` | `pwsh -NoProfile -File tests/locallab.ps1` | Dev harness: runs the checks and optionally installs this checkout (`-Install`, `-FullInstall`, `-Wizard`); `-Restore` copies back only the two profile files from its last backup in `%TEMP%`, not Windows Terminal settings or caches | 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. @@ -523,4 +540,6 @@ Further ideas: ## License -MIT. Use it, fork it, rip out what you need. Credit appreciated, not required. +MIT, see [LICENSE](LICENSE). The profile is derived from [ChrisTitusTech/powershell-profile](https://github.com/ChrisTitusTech/powershell-profile), so the license carries his 2025 copyright next to this project's. Use it, fork it, take what you need; copies and substantial portions must keep the copyright and permission notice. + +Not affiliated with or endorsed by Microsoft. diff --git a/SECURITY.md b/SECURITY.md index 21664f2..cbc1555 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,32 +29,38 @@ The following areas are in scope for security reports: ## 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. +- **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 --source-ref refs/tags/vX.Y.Z --signer-workflow 26zl/PowerShellPerfect/.github/workflows/release.yml` checks with Sigstore that a file was built by this repository's release workflow from that tag (`--repo` alone only checks the repository); 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. +- **Combined hash format**, so it can be recomputed independently: take the uppercase SHA-256 hex of each downloaded file, join them as `profile::theme::terminal:` (`Update-Profile` writes `NONE` for a config file it did not download), and take the SHA-256 of the UTF-8 bytes of that string. `setup.ps1` itself is not part of the bundle hash. +- **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 a child PowerShell in the same window; 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`. +- **Third-party components** are pinned: Oh My Posh themes to release `v31.8.0` with SHA-256 for the default and preset themes, the portable Oh My Posh binary to release `v31.9.1` with the SHA-256 of each architecture's build plus its Authenticode signer, 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 are fetched from the pinned release without a hash, and theme URLs you set in `user-settings.json` are fetched from `raw.githubusercontent.com` only. A theme that can run commands on every prompt (a `command` segment, a template function such as `cmd`, `readFile` or `getHostByName`, or `extends`) is refused unless you set `theme.allowCommands`; none of the official themes uses any of these. +- **Old Oh My Posh builds.** Oh My Posh before 29.35.1 runs template code taken from folder names (CVE-2026-73505), so the profile never renders the prompt with such a build: it uses the portable build that setup installs when that is present, and otherwise the plain prompt with a warning. `psp-doctor` reports such an install as FAIL. `setup.ps1` and `Update-Tools` install the portable build next to it and leave the old install in place for you to update or remove. The minimum is `OhMyPoshMinimum` in `Get-PspComponentPins`. +- **Software installs.** `setup.ps1` installs the portable Oh My Posh build and winget packages (plus the editor you pick, when it is missing and you agree), and accepts the winget source and package agreements for you; `Update-Profile` installs nothing unless you pass `-InstallMissingTools`. ## Security Measures -- 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 `. +- PSReadLine history filters out lines containing: `password`, `secret`, `token`, `api[_-]?key`, `connectionstring`, `credential`, `bearer`, `asplaintext` (`ConvertTo-SecureString -AsPlainText`), 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. +- When in CI or when `$env:AI_AGENT` is set, the profile skips network calls and interactive UI at load, and setup never prompts (it still downloads what it installs), reducing exposure in automated or AI/agent environments. `CI=false` and `CI=0` do not count as CI. +- `envload` skips variables that change which programs run, what they load, or where they connect (`PATH`, `PSModulePath`, `NODE_OPTIONS`, `GIT_*`, `KUBECONFIG`, cloud credential files, `*_OPTS`, `*_CONFIG`, proxies and similar) unless you pass `-AllowSensitive`, so a `.env` file from a cloned repository cannot redirect them silently. - 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 -The profile has four mechanisms that execute code from places other than the main profile file. All are user-owned and opt-in, but the trust model should be explicit: +The profile has six mechanisms that execute code from places other than the main profile file. All are user-owned and opt-in, but the trust model should be explicit: - **`profile_user.ps1`** - dot-sourced every profile load. Lives in the user's `$PROFILE` directory. Equivalent to editing the profile itself. - **`plugins/*.ps1`** - dot-sourced every profile load. Lives in `%LOCALAPPDATA%\PowerShellProfile\plugins\`. Each file is isolated so one crash doesn't break others, but a plugin has full profile trust. -- **`user-settings.json` `commandOverrides`** - values are passed to `[scriptblock]::Create()` and defined as functions. **Default off** - requires explicit opt-in via `features.commandOverrides = true` in the same file, so a silently-edited JSON file cannot redefine commands on next shell launch. When the feature is off but entries exist, the profile prints a notice at startup and ignores them. Do not copy `user-settings.json` from untrusted sources. +- **`user-settings.json` `commandOverrides`** - values are passed to `[scriptblock]::Create()` and defined as functions. **Default off** - requires explicit opt-in via `features.commandOverrides = true`. The opt-in lives in the same file, so anyone who can edit it can turn overrides on; every interactive start therefore prints a notice while overrides are active, and when the feature is off but entries exist, the profile prints a notice 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. `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. -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. +- **Oh My Posh themes that run commands** - command segments and template functions such as `cmd`, `readFile` or `getHostByName` run on every prompt, and `extends` pulls in another config. Setup, `Update-Profile` and the profile itself refuse such a theme unless `theme.allowCommands = true` is set in `user-settings.json`. +- **Generated init scripts** - `zoxide-init.ps1` and the kubectl/gh/docker completion scripts in `%LOCALAPPDATA%\PowerShellProfile` are generated by those tools and dot-sourced; anyone who can write that folder can change them. + +All but `.psprc.ps1` need write access to your profile or cache folders. `.psprc.ps1` files can arrive through version control, which is why their trust is bound to the approved content rather than the directory. ## Data Flows @@ -71,11 +77,12 @@ Commands that send data to a third party (all over HTTPS). Nothing is sent unles | `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 [-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` | +| `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`; the free public API is for personal, non-commercial use under VirusTotal's terms | | `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 | +| `setup.ps1` installs | winget sources, the PowerShell Gallery, `github.com` (Nerd Fonts and the portable Oh My Posh build), the Oh My Posh repository | None beyond the requests | +| `Update-Tools`, `Update-Profile -InstallMissingTools` | `github.com` (the portable Oh My Posh build), winget sources, the PowerShell Gallery | 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 a187c73..aa7cd0a 100644 --- a/setprofile.ps1 +++ b/setprofile.ps1 @@ -24,7 +24,7 @@ param() Set-StrictMode -Version 1.0 $ErrorActionPreference = 'Stop' -# Copy the profile to PS5 and PS7 directories while normalizing agent detection. +# Normalize known agent env vars to AI_AGENT (same as the 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' } $source = if ($PSScriptRoot) { Join-Path $PSScriptRoot 'Microsoft.PowerShell_profile.ps1' } else { $null } if (-not $source -or -not (Test-Path -LiteralPath $source -PathType Leaf)) { @@ -68,8 +68,10 @@ foreach ($dir in $profileDirs) { $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 + # Only timestamped backups rotate, newest by the stamp in the name, since Copy-Item keeps the source LastWriteTime. Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | + Where-Object { $_.Name -match '^oldprofile\.\d{8}-\d{6}\.ps1$' } | + Sort-Object Name -Descending | Select-Object -Skip 5 | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } } Copy-Item -LiteralPath $source -Destination $targetProfile -Force @@ -80,7 +82,9 @@ foreach ($dir in $profileDirs) { $failed++ } } -if ([Environment]::UserInteractive -and -not [bool]$env:CI -and -not [bool]$env:AI_AGENT -and -not $WhatIfPreference) { +# CI=false or CI=0 (set by some shells and tools) does not count as a CI run. +$isCi = [bool]$env:CI -and $env:CI -ne 'false' -and $env:CI -ne '0' +if ([Environment]::UserInteractive -and -not $isCi -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 = $_ } diff --git a/setup.ps1 b/setup.ps1 index 0352cc0..42dfe16 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -10,7 +10,8 @@ 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. + eza, zoxide, fzf, bat and ripgrep and accepts the winget source and package agreements for you. Oh My Posh + is installed as the pinned portable release build (SHA-256 and signer checked), with winget as the fallback. 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. @@ -67,7 +68,8 @@ .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. + 1 = nothing was applied (verification failed, unsupported system) or the profile could not be installed; + 2 = the wizard was cancelled, so nothing was 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. #> @@ -137,9 +139,11 @@ $script:WizardEditor = $null $script:WizardTelemetryHandled = $false $script:TranscriptStarted = $false $script:ErrorReported = $false +$script:SetupCancelled = $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 +# CI=false or CI=0, which some build tools tell users to set globally, does not count as CI. +$script:IsCiHost = $CiMode -or [bool]$env:GITHUB_ACTIONS -or ([bool]$env:CI -and $env:CI -ne 'false' -and $env:CI -ne '0') $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 } } @@ -177,6 +181,8 @@ function Invoke-DownloadWithRetry { [int]$MaxAttempts = 2, [int]$BackoffSec = 2 ) + # The progress display slows large downloads on Windows PowerShell 5.1 a lot; this scope only. + $ProgressPreference = 'SilentlyContinue' for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue @@ -338,6 +344,17 @@ function Initialize-InstallBundle { } Write-PspHost "Install bundle verified: $combined" -ForegroundColor Green } + elseif ($LocalRepo -and (Test-Path -LiteralPath (Join-Path $LocalRepo 'SHA256SUMS.txt') -PathType Leaf)) { + # Release files downloaded next to setup.ps1 come with SHA256SUMS.txt, and every bundle file must match it. + $localSums = @{} + foreach ($line in [System.IO.File]::ReadAllLines((Join-Path $LocalRepo 'SHA256SUMS.txt'))) { + if ($line -match '^([0-9a-fA-F]{64})\s+\*?(\S+)$') { $localSums[$Matches[2]] = $Matches[1].ToUpperInvariant() } + } + foreach ($key in $names.Keys) { + if ($localSums[$names[$key]] -ne $hashes[$key]) { throw "$($names[$key]) in $LocalRepo does not match the SHA256SUMS.txt next to it." } + } + Write-PspHost "Install bundle matches the SHA256SUMS.txt in $LocalRepo." -ForegroundColor Green + } elseif ($LocalRepo) { Write-PspHost "Installing from local files in $LocalRepo (not hash-checked). Bundle hash of these files: $combined" -ForegroundColor DarkGray } @@ -376,10 +393,15 @@ $script:SharedFunctionNames = @( 'Get-PspUserSettingsTemplate', 'Merge-PspUserSettings', 'Test-PspTrustedThemeUrl', 'Read-UserSettingsForWrite', 'Get-PspShellPath', 'Get-WtProfileList', 'Test-PspProfileFile', 'Get-PspOriginalProfileSource' ) +# Helpers newer than 1.0.0 are loaded when the verified profile defines them, so setup can still install an older release with -Ref. +$script:OptionalSharedFunctionNames = @( + 'Find-ProfileExecutable', 'Get-OhMyPoshPortablePath', 'Install-OhMyPoshPortable', 'Remove-WtRetiredKeybinding', 'Restore-WtRecordedKeybinding', + 'Get-PspOmpThemeRisk', 'Get-PspExecutableVersion', 'Test-PspOmpVersionSafe' +) # 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) + param([Parameter(Mandatory)][string]$ProfilePath, [Parameter(Mandatory)][string[]]$Names, [string[]]$OptionalNames = @()) $tokens = $null $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($ProfilePath, [ref]$tokens, [ref]$parseErrors) @@ -391,6 +413,7 @@ function Get-SharedFunctionDefinitions { $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] } + foreach ($name in $OptionalNames) { if ($defined.ContainsKey($name)) { $defined[$name] } } } # Define the install wizard's curated Windows Terminal color schemes. @@ -436,9 +459,7 @@ $script:CuratedFonts = @( @{ 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. +# Editor choices and their winget IDs; this must stay above Start-InstallWizard, which uses it. $EditorCandidates = @( @{ Cmd = 'code'; Display = 'Visual Studio Code'; WingetId = 'Microsoft.VisualStudioCode' } @{ Cmd = 'nvim'; Display = 'Neovim'; WingetId = 'Neovim.Neovim' } @@ -455,7 +476,7 @@ function Get-OmpThemeUrl { '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. +# Prompt for one wizard choice by number or partial name. function Select-WizardItem { param( [Parameter(Mandatory)][string]$Title, @@ -521,8 +542,7 @@ function Save-WizardChoices { 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. + # The install steps skip their template when this file already exists, so the wizard adds the header that explains it. if (-not $s.PSObject.Properties['_comment']) { $s | Add-Member -NotePropertyName '_comment' -NotePropertyValue 'User overrides for terminal, theme, and profile behavior. Only add keys you want to override.' -Force } @@ -687,6 +707,13 @@ function Resolve-WizardState { 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) + # A step whose saved answer was dropped is asked again instead of silently falling back to the default. + $stepOf = @{ Theme = 'Theme'; Scheme = 'Scheme'; Font = 'Font'; TabBar = 'TabBar'; Terminal = 'Terminal'; PSReadLine = 'PSReadLine'; Background = 'Background'; Editor = 'Editor'; TelemetryOptOut = 'Telemetry'; Features = 'Features' } + $rejected = @(foreach ($key in $stepOf.Keys) { if ($null -ne (Get-PspProp $Saved $key) -and $null -eq $valid[$key] -and $valid.CompletedSteps -contains $stepOf[$key]) { $stepOf[$key] } }) + if ($rejected.Count -gt 0) { + $valid.CompletedSteps = @($valid.CompletedSteps | Where-Object { $rejected -notcontains $_ }) + Write-PspHost (" Saved answers that are no longer valid are asked again: {0}." -f (($rejected | Sort-Object) -join ', ')) -ForegroundColor Yellow + } return $valid } @@ -727,7 +754,9 @@ function Select-PreferredEditor { } if (-not (Get-Command $chosen.Cmd -ErrorAction Ignore)) { Write-PspHost " '$($chosen.Cmd)' is not installed." -ForegroundColor Yellow - $confirm = Read-Host " Use anyway? [y/N]" + # Yes means Resolve-ConfiguredEditor installs it with winget later in setup. + $question = if ($chosen.WingetId) { ' Install it with winget during setup (this accepts its license)? [y/N]' } else { ' Use it anyway? [y/N]' } + $confirm = Read-Host $question if ($confirm -notmatch '^[Yy]') { Write-PspHost " Using default instead." -ForegroundColor Yellow return $EditorCandidates[$defaultChoice].Cmd @@ -1104,9 +1133,7 @@ function Install-NerdFonts { 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. + # A shell font install lands machine-wide when elevated and per-user otherwise, so both stores are checked. $fontStores = @( (Join-Path $env:SystemRoot 'Fonts') (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts') @@ -1146,10 +1173,13 @@ function Install-NerdFonts { return $true } catch { - Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' Write-PspHost " Failed to install ${FontDisplayName}: $_" -ForegroundColor Red return $false } + finally { + # Also runs on Ctrl+C, which skips the catch block. + Remove-SafeTempDirectory -Path $tempRoot -NamePrefix 'psp-font-' + } } # Download an Oh My Posh theme into the cache folder, verifying its SHA-256 when one is known. @@ -1160,8 +1190,12 @@ function Install-OhMyPoshTheme { [string]$ThemeName, [Parameter(Mandatory)] [string]$ThemeUrl, - [string]$Sha256 + [string]$Sha256, + # Accept a theme that can run commands on every prompt (theme.allowCommands in user-settings.json). + [switch]$AllowCommands ) + # Profiles without Get-PspOmpThemeRisk skip the check for themes that run commands. + $canCheckThemeRisk = [bool](Get-Command Get-PspOmpThemeRisk -CommandType Function -ErrorAction Ignore) if ($ThemeName -notmatch '^[A-Za-z0-9._-]+$') { Write-PspHost " Refusing theme with an invalid name: $ThemeName" -ForegroundColor Red return $false @@ -1176,8 +1210,10 @@ function Install-OhMyPoshTheme { $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()) + $existingTheme = Read-JsonFile $themeFilePath + if ($null -ne $existingTheme) { + $alreadyValid = (-not $Sha256 -or ((Get-FileHash -LiteralPath $themeFilePath -Algorithm SHA256).Hash -eq $Sha256.ToUpperInvariant())) -and + ($AllowCommands -or -not $canCheckThemeRisk -or (Get-PspOmpThemeRisk -Theme $existingTheme).Count -eq 0) } } catch { $null = $_ } @@ -1190,11 +1226,17 @@ function Install-OhMyPoshTheme { 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' } + $downloadedTheme = Read-JsonFile $tempThemePath + if ($null -eq $downloadedTheme) { 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)" } } + $themeRisks = @() + if ($canCheckThemeRisk) { $themeRisks = Get-PspOmpThemeRisk -Theme $downloadedTheme } + if (@($themeRisks).Count -gt 0 -and -not $AllowCommands) { + throw ("theme '{0}' can run commands on every prompt ({1}). Set theme.allowCommands = true in user-settings.json to use it" -f $ThemeName, (@($themeRisks) -join ', ')) + } Move-Item -LiteralPath $tempThemePath -Destination $themeFilePath -Force $tempThemePath = $null Write-PspHost " Theme '$ThemeName' downloaded." -ForegroundColor Green @@ -1224,7 +1266,7 @@ function Install-WingetPackage { return $false } 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') + $result = Invoke-SetupNative -FilePath $winget.Source -ArgumentList @('install', '-e', '--id', $Id, '--source', 'winget', '--accept-source-agreements', '--accept-package-agreements') if ($result.ExitCode -eq 0) { Write-PspHost " $Name installed." -ForegroundColor Green return $true @@ -1253,7 +1295,7 @@ function Resolve-ConfiguredEditor { $resolvedEditor = 'notepad' } elseif (Install-WingetPackage -Name $chosen.Display -Id $chosen.WingetId) { - # Exclude empty PATH entries that Windows could interpret as the current directory. + # Reload PATH so the new editor resolves. Update-SessionPathFromRegistry } else { @@ -1329,13 +1371,10 @@ function Install-ProfileFiles { $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. + # Keep the pre-install profile once as profile.original.ps1, which rotation never touches; a copy of this profile there is replaced. $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 @@ -1347,8 +1386,10 @@ function Install-ProfileFiles { $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 + # Only timestamped backups rotate, newest by the stamp in the name, since Copy-Item keeps the source LastWriteTime. Get-ChildItem -LiteralPath $dir -Filter 'oldprofile*.ps1' -File -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | + Where-Object { $_.Name -match '^oldprofile\.\d{8}-\d{6}\.ps1$' } | + Sort-Object Name -Descending | Select-Object -Skip 5 | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } } Move-Item -LiteralPath $staged -Destination $targetProfile -Force @@ -1461,7 +1502,17 @@ function Invoke-SetupInstall { 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 + Write-PspHost " Wizard font install did not complete; step [4/10] installs the default font, which Windows Terminal then uses." -ForegroundColor Yellow + # The saved face would point Windows Terminal at a font that is not installed, so it is dropped and the default applies. + try { + $savedSettings = Read-UserSettingsForWrite -Path $userSettingsPath + $savedFont = Get-PspPath $savedSettings 'defaults.font' + if ($savedFont -and [string](Get-PspProp $savedFont 'face') -eq $wizChoices.Font.DisplayName -and $PSCmdlet.ShouldProcess($userSettingsPath, 'Remove the font face that failed to install')) { + Remove-PspProp $savedFont 'face' + Write-Utf8FileAtomic -Path $userSettingsPath -Content ($savedSettings | ConvertTo-Json -Depth 20) + } + } + catch { Write-PspHost " Could not reset the font in user-settings.json: $_" -ForegroundColor Yellow } } } @@ -1472,8 +1523,11 @@ function Invoke-SetupInstall { if ($wizChoices.TelemetryOptOut -and -not (Test-TelemetryOptOutSet)) { Set-TelemetryOptOut -CacheDir $configCachePath } } catch { - if ($_.Exception.Message -eq 'WizardCancelled') { - Write-PspHost 'Continuing setup without wizard overrides.' -ForegroundColor Yellow + if ($_.Exception.Message -like 'WizardCancelled*') { + # Declining the summary, or leaving another running wizard alone, stops setup before anything is installed. + Write-PspHost 'Setup cancelled: nothing was installed.' -ForegroundColor Yellow + $script:SetupCancelled = $true + return $false } else { Write-Warning ("Wizard failed: {0}. Continuing with defaults; run setup.ps1 -Wizard -Resume to finish it." -f $_.Exception.Message) @@ -1510,7 +1564,7 @@ function Invoke-SetupInstall { } 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 "winget (App Installer) not found - skipping optional tool installs (eza, bat, fzf, zoxide, ripgrep); Oh My Posh still installs as the portable build." -ForegroundColor Yellow Write-PspHost " Install it later from the Microsoft Store or https://aka.ms/getwinget to add those tools." -ForegroundColor DarkYellow } @@ -1557,10 +1611,11 @@ function Invoke-SetupInstall { Write-PspHost " Skipped (non-interactive). Default: code, notepad" -ForegroundColor Yellow } - # 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') + '*')) { + # An install older than the minimum stays, and the portable build installed next to it is what the prompt uses. + $ompTooOld = $ompPath -and -not $script:IsCiHost -and (Get-Command Test-PspOmpVersionSafe -CommandType Function -ErrorAction Ignore) -and -not (Test-PspOmpVersionSafe -Path $ompPath) + if ($ompPath -and $ompPath -notlike ((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps') + '*') -and -not $ompTooOld) { Write-PspHost " Oh My Posh already present at $ompPath (preserved)." -ForegroundColor Green $ompInstalled = $true } @@ -1569,15 +1624,30 @@ function Invoke-SetupInstall { $ompInstalled = $true } else { - $ompInstalled = Install-WingetPackage -Name "Oh My Posh" -Id "JanDeDobbeleer.OhMyPosh" + if ($ompTooOld) { + Write-PspHost (' Oh My Posh at {0} is older than {1} and runs code taken from folder names (CVE-2026-73505); installing the portable build for the prompt. Update or remove that install.' -f $ompPath, (Get-PspComponentPins).OhMyPoshMinimum) -ForegroundColor Yellow + } + # Portable build first (hash and signer checked); winget is the fallback. + $ompInstalled = $false + if (Get-Command Install-OhMyPoshPortable -CommandType Function -ErrorAction Ignore) { + $ompInstalled = [bool](Install-OhMyPoshPortable -Confirm:$false) + if ($ompInstalled -and $ompPath -and -not $ompTooOld) { + Write-PspHost " The MSIX build at $ompPath is no longer used. Remove it with: winget uninstall -e --id JanDeDobbeleer.OhMyPosh" -ForegroundColor DarkGray + } + } + if (-not $ompInstalled -and -not $WhatIfPreference) { + $ompInstalled = Install-WingetPackage -Name "Oh My Posh" -Id "JanDeDobbeleer.OhMyPosh" + } + elseif ($WhatIfPreference) { $ompInstalled = $true } } # Wizard OMP-theme override wins if present; otherwise use theme.json (with user-settings overrides). $configTheme = Get-PspProp $profileConfig 'theme' + $allowThemeCommands = [bool](Get-PspProp $configTheme 'allowCommands') if ($script:WizardOmpTheme) { - $themeInstalled = Install-OhMyPoshTheme -ThemeName $script:WizardOmpTheme.Name -ThemeUrl $script:WizardOmpTheme.Url -Sha256 ([string](Get-PspProp $script:WizardOmpTheme 'Sha256')) + $themeInstalled = Install-OhMyPoshTheme -ThemeName $script:WizardOmpTheme.Name -ThemeUrl $script:WizardOmpTheme.Url -Sha256 ([string](Get-PspProp $script:WizardOmpTheme 'Sha256')) -AllowCommands:$allowThemeCommands } 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')) + $themeInstalled = Install-OhMyPoshTheme -ThemeName ([string]$configTheme.name) -ThemeUrl ([string]$configTheme.url) -Sha256 ([string](Get-PspProp $configTheme 'sha256')) -AllowCommands:$allowThemeCommands } else { $reason = if (-not $profileConfig) { "theme.json missing" } @@ -1587,14 +1657,13 @@ function Invoke-SetupInstall { $themeInstalled = $false } # Invalidate generated init caches. - foreach ($cacheFile in @('zoxide-init.ps1', 'omp-init.ps1')) { + foreach ($cacheFile in @('zoxide-init.ps1', 'omp-init.ps1', 'omp-secondary-prompt.txt', 'omp-theme-fast.json')) { $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') @@ -1643,7 +1712,6 @@ function Invoke-SetupInstall { } } - # zoxide Install Write-PspHost "[6/10] zoxide" -ForegroundColor Cyan $zoxideInstalled = $true if ($script:IsCiHost) { @@ -1665,7 +1733,7 @@ function Invoke-SetupInstall { $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 + Install-Module -Name PSFzf -RequiredVersion $psfzfVersion -Repository PSGallery -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop Write-PspHost " PSFzf $psfzfVersion installed." -ForegroundColor Green } catch { @@ -1732,9 +1800,10 @@ function Invoke-SetupInstall { ManifestPath = (Join-Path $configCachePath 'wt-changes.json') Defaults = $wtDefaults WindowsTerminal = $wtTheme - Keybindings = Get-PspArray $terminalConfig 'keybindings' NoLogo = $true } + # Keybindings sync only from a loaded terminal-config.json, which also retires bindings it no longer lists. + if ($null -ne $terminalConfig) { $wtArgs.Keybindings = Get-PspArray $terminalConfig 'keybindings' } if (Update-WindowsTerminalSettingsFile @wtArgs) { $schemeLabel = if (Get-PspProp $wtTheme 'colorScheme') { $wtTheme.colorScheme } else { '(unchanged)' } Write-PspHost " Windows Terminal configured: $wtSettingsPath (scheme: $schemeLabel)." -ForegroundColor Green @@ -1799,6 +1868,8 @@ function Restart-SetupTerminal { if (-not $dir -or -not (Test-Path -LiteralPath $dir -PathType Container -ErrorAction SilentlyContinue)) { $dir = [Environment]::GetFolderPath('UserProfile') } + # Windows Terminal splits its command line at every ';', so a folder containing one is not passed to wt -d. + if ($dir.Contains(';')) { $dir = [Environment]::GetFolderPath('UserProfile') } $shellExe = Get-PspShellPath $wt = Get-Command wt.exe -CommandType Application -ErrorAction Ignore | Select-Object -First 1 if ($wt) { @@ -1812,9 +1883,7 @@ function Restart-SetupTerminal { exit $ExitCode } -# --------------------------------------------------------------------------------------------- # Main flow -# --------------------------------------------------------------------------------------------- $exitCode = 1 try { $onWindows = (-not (Test-Path Variable:\IsWindows)) -or $IsWindows @@ -1848,7 +1917,7 @@ try { } # 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)) { + foreach ($definition in (Get-SharedFunctionDefinitions -ProfilePath $script:Bundle.Profile -Names $script:SharedFunctionNames -OptionalNames $script:OptionalSharedFunctionNames)) { . ([scriptblock]::Create($definition)) } @@ -1869,7 +1938,7 @@ try { } $installed = Invoke-SetupInstall -Bundle $script:Bundle - $exitCode = if ($installed -or $WhatIfPreference) { 0 } else { 1 } + $exitCode = if ($installed -or $WhatIfPreference) { 0 } elseif ($script:SetupCancelled) { 2 } else { 1 } # A failed install keeps this window open with its messages. if ($installed) { Restart-SetupTerminal -ExitCode $exitCode } } diff --git a/terminal-config.json b/terminal-config.json index a4557ef..59b64b6 100644 --- a/terminal-config.json +++ b/terminal-config.json @@ -18,10 +18,5 @@ "version": "3.5.1", "sha256": "1298BF92698AFA06185CF1D05E6AE05F2D8A1E8C3CB45DDF4C3035168AB342A1" }, - "keybindings": [ - { - "keys": "ctrl+a", - "command": "selectAll" - } - ] + "keybindings": [] } diff --git a/tests/ci-functional.ps1 b/tests/ci-functional.ps1 index e856f40..4542418 100644 --- a/tests/ci-functional.ps1 +++ b/tests/ci-functional.ps1 @@ -347,6 +347,7 @@ Invoke-TestCase -Name 'Install profile in sandbox' -Code { Set-Variable -Name PROFILE -Scope Global -Value $script:sandboxPs7Profile & (Join-Path $repoRoot 'setprofile.ps1') | Out-Null + if ($LASTEXITCODE -ne 0) { throw "setprofile.ps1 failed (exit $LASTEXITCODE)" } if (-not (Test-Path $script:sandboxPs7Profile)) { throw "Missing installed profile: $($script:sandboxPs7Profile)" } if (-not (Test-Path $script:sandboxPs5Profile)) { throw "Missing installed profile: $($script:sandboxPs5Profile)" } @@ -567,9 +568,9 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Invoke-CommandProbe -Command 'Uninstall-Profile' -Code { Uninstall-Profile -All -WhatIf -Confirm:$false | Out-Null } Invoke-CommandProbe -Command 'Invoke-ProfileWizard' -Code { # Confirm -WhatIf prevents setup wizard downloads. - $preFiles = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*.ps1' -ErrorAction SilentlyContinue) + $preFiles = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*' -ErrorAction SilentlyContinue) Invoke-ProfileWizard -WhatIf -Confirm:$false | Out-Null - $postFiles = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*.ps1' -ErrorAction SilentlyContinue) + $postFiles = @(Get-ChildItem -Path $env:TEMP -Filter 'psp-reconfigure-*' -ErrorAction SilentlyContinue) if ($postFiles.Count -gt $preFiles.Count) { throw "Invoke-ProfileWizard -WhatIf created temp files it should not have: $($postFiles.Name -join ', ')" } @@ -585,7 +586,7 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { } Invoke-CommandProbe -Command 'Invoke-DownloadWithRetry' -SkipReason 'Internal helper (covered by setup tests)' Invoke-CommandProbe -Command 'Invoke-WithTimeout' -SkipReason 'Internal helper (used by OMP/zoxide init)' - Invoke-CommandProbe -Command 'Merge-JsonObject' -SkipReason 'Internal helper nested in Update-Profile' + Invoke-CommandProbe -Command 'Merge-JsonObject' -SkipReason 'Internal helper shared with setup.ps1; tested by tests/test.ps1 step 14' # Git Invoke-CommandProbe -Command 'gs' -Code { @@ -631,11 +632,9 @@ Invoke-TestCase -Name 'Execute full command matrix' -Code { Invoke-CommandProbe -Command 'g' -SkipReason 'Depends on local zoxide jump target' # Files and navigation - Invoke-CommandProbe -Command 'ls' -Code { ls $workspace | Out-Null } Invoke-CommandProbe -Command 'la' -Code { la $workspace | Out-Null } Invoke-CommandProbe -Command 'll' -Code { ll $workspace | Out-Null } Invoke-CommandProbe -Command 'lt' -Code { lt $workspace | Out-Null } - Invoke-CommandProbe -Command 'cat' -Code { cat $textFile | Out-Null } # bcat is only defined when bat is installed; exercise it when present, otherwise record a skip. if (Get-Command bat -ErrorAction Ignore) { Invoke-CommandProbe -Command 'bcat' -Code { bcat $textFile | Out-Null } @@ -1250,7 +1249,6 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { 'Test-ProfileHistorySafeLine' 'Save-TrustedDirectories' 'Read-UserSettingsForWrite' - 'Get-WindowsTerminalSettingsPath' 'Get-WindowsTerminalSettingsPaths' 'Push-TabTitle' 'Pop-TabTitle' @@ -1270,6 +1268,18 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code { 'Copy-PspObject' 'Merge-PspUserSettings' 'ConvertTo-PspSortedNode' + 'Find-ProfileExecutable' + 'Test-ProfileModuleInstalled' + 'Get-OhMyPoshPortablePath' + 'Install-OhMyPoshPortable' + 'Complete-OhMyPoshCommand' + 'Get-OhMyPoshChildPath' + 'Get-ProfileEnvironmentFingerprint' + 'ConvertTo-ProfileFastOmpTheme' + 'Import-ProfileFzfModule' + 'Invoke-ProfileLazyCompleter' + 'Register-ProfileHistoryHandler' + 'ConvertTo-ProfilePrettyJson' ) # 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]' } diff --git a/tests/lib/Sandbox.ps1 b/tests/lib/Sandbox.ps1 index c01cd57..30bb897 100644 --- a/tests/lib/Sandbox.ps1 +++ b/tests/lib/Sandbox.ps1 @@ -109,7 +109,7 @@ function Remove-PspTestStubs { $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. + # Registry PATH entries would add the real machine's tools behind the sandbox's stubs. function Update-SessionPathFromRegistry { } # Font removal stays inside the sandbox. if ($script:PSP) { diff --git a/tests/locallab.ps1 b/tests/locallab.ps1 index e0c8731..483e60a 100644 --- a/tests/locallab.ps1 +++ b/tests/locallab.ps1 @@ -27,7 +27,6 @@ param( if ($Wizard -and -not $FullInstall) { $FullInstall = $true } $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') @@ -48,7 +47,7 @@ function Ok { param([string]$Name, [string]$Detail = '') else { Write-Host " OK $Name" -ForegroundColor Green } } -# --- Restore flow: find latest backup and put it back --- +# Restore flow: find latest backup and put it back if ($Restore) { Step 'Restore live profile from backup' $backups = Get-ChildItem -Path $env:TEMP -Directory -Filter 'psp-locallab-backup-*' -ErrorAction SilentlyContinue | @@ -85,14 +84,14 @@ if (-not ($SkipLint -and $SkipParse -and $SkipSmoke -and $SkipTest -and -not $Fu } $elevatedArgs = if ($AllowElevated) { @('-AllowElevated') } else { @() } -# --- Validate files exist --- +# 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', 'tests/unit.ps1', 'PSScriptAnalyzerSettings.psd1')) { if (Test-Path $f) { Ok $f } else { Fail $f 'missing'; } } -# --- 1. PSScriptAnalyzer (same version and settings as CI) --- +# 1. PSScriptAnalyzer (same version and settings as CI) if (-not $SkipLint) { Step 'PSScriptAnalyzer (tests/lint.ps1)' # Never installs modules: without the pinned analyzer the step is skipped with the install command. @@ -107,7 +106,7 @@ if (-not $SkipLint) { } } -# --- 2. PS5 parse-check (uses real powershell.exe) --- +# 2. PS5 parse-check (uses real powershell.exe) if (-not $SkipParse) { Step 'PS5 parse-check' $ps5 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' @@ -130,7 +129,7 @@ if (-not $SkipParse) { } } -# --- 3. Non-interactive smoke load inside a sandbox (your profile_user.ps1 and plugins are not run) --- +# 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 (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 "'", "''")'" @@ -139,7 +138,7 @@ if (-not $SkipSmoke) { else { Fail 'smoke' "exit $LASTEXITCODE" } } -# --- 4. Optional: test.ps1 local test suite (includes tests/unit.ps1) --- +# 4. tests/test.ps1, which includes tests/unit.ps1 (skipped with -SkipTest) if (-not $SkipTest -and (Test-Path 'tests/test.ps1')) { Step 'Run tests/test.ps1 (-SkipPS5)' $testOutput = & pwsh -NoProfile -File tests/test.ps1 -SkipPS5 @elevatedArgs 2>&1 @@ -149,7 +148,7 @@ if (-not $SkipTest -and (Test-Path 'tests/test.ps1')) { else { Fail 'tests/test.ps1' "exit $LASTEXITCODE" } } -# --- 5. Optional: ci-functional.ps1 (sandboxed, third-party endpoints mocked) --- +# 5. With -Functional: tests/ci-functional.ps1 (sandboxed, third-party endpoints mocked) if ($Functional) { Step 'Run ci-functional.ps1' & pwsh -NoProfile -File tests/ci-functional.ps1 @elevatedArgs @@ -157,7 +156,7 @@ if ($Functional) { else { Fail 'tests/ci-functional.ps1' "exit $LASTEXITCODE" } } -# --- 6. Install to live profile dirs (replaces GitHub version) --- +# 6. Install to live profile dirs (replaces GitHub version) if ($Install -or $FullInstall) { if ($script:failures.Count -gt 0) { Write-Host '' @@ -204,7 +203,7 @@ if ($Install -or $FullInstall) { } } -# --- Summary --- +# Summary Write-Host '' Write-Host '=========================================' -ForegroundColor Cyan if ($script:failures.Count -eq 0) { @@ -212,7 +211,7 @@ if ($script:failures.Count -eq 0) { if ($Install -or $FullInstall) { Write-Host ' Local profile is now live. Open a new pwsh to try it.' -ForegroundColor Green } - elseif (-not $Restore) { + else { Write-Host ' To install this version live: pwsh -NoProfile -File tests/locallab.ps1 -Install' -ForegroundColor DarkGray } exit 0 diff --git a/tests/rawhunt.ps1 b/tests/rawhunt.ps1 index a88913e..e2f708a 100644 --- a/tests/rawhunt.ps1 +++ b/tests/rawhunt.ps1 @@ -69,7 +69,6 @@ function Get-SettledClipboard { Write-Host "`n==================== RAW Bug Hunt ====================" -ForegroundColor Cyan -# Resolve repoRoot as the parent of tests/. $repoRoot = Split-Path -Parent $PSScriptRoot $profilePath = Join-Path $repoRoot 'Microsoft.PowerShell_profile.ps1' $setupPath = Join-Path $repoRoot 'setup.ps1' @@ -81,15 +80,6 @@ Remove-Item env:CI Write-Host "Profile loaded from: $repoRoot" -ForegroundColor DarkGray -# Extract nested Merge-JsonObject via AST (lives inside Update-Profile) -$tokens = $null; $parseErrors = $null -$ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) -$mergeFn = $ast.FindAll({ - $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $args[0].Name -eq 'Merge-JsonObject' -}, $true) | Select-Object -First 1 -if ($mergeFn) { . ([scriptblock]::Create($mergeFn.Extent.Text)) } - # Extract setup.ps1 functions 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) @@ -106,11 +96,9 @@ $ws = Join-Path $env:TEMP "psp-raw-$([System.IO.Path]::GetRandomFileName())" New-Item -ItemType Directory -Path $ws -Force | Out-Null $origDir = Get-Location -# ##################################################################### -# PART 1: UTILITY FUNCTIONS -# ##################################################################### +# PART 1: UTILITY FUNCTIONS -# --- File Operations --- +# File Operations Write-Host "`n--- File Operations ---" -ForegroundColor Magenta $tf = Join-Path $ws "testfile.txt" @@ -231,7 +219,7 @@ T 'trash' { if (Test-Path $trashFile) { throw "file not moved to recycle bin" } } -# --- Navigation --- +# Navigation Write-Host "`n--- Navigation ---" -ForegroundColor Magenta T 'docs' { @@ -250,21 +238,19 @@ T 'dtop' { if ($after.Path -notmatch 'Desktop') { throw "not in Desktop: $after" } } -# --- Listing (eza/bat) --- +# Listing (eza/bat) Write-Host "`n--- Listing ---" -ForegroundColor Magenta -T 'ls' { & (Get-Command 'ls').Name $ws } T 'la' { la $ws } T 'll' { ll $ws } T 'lt' { lt $ws } -T 'cat' { & (Get-Command 'cat').Name $tf } T 'grep (in file)' { Set-Location $ws grep "hello" $ws Set-Location $origDir } -# --- System --- +# System Write-Host "`n--- System ---" -ForegroundColor Magenta T 'uptime' { & (Get-Command 'uptime').Name } @@ -278,7 +264,7 @@ T 'eventlog' { & (Get-Command 'eventlog').Name 5 } T 'pgrep' { $r = pgrep "pwsh"; if (-not $r) { throw "no pwsh process found" } } T 'ports' { ports } -# --- Network --- +# Network Write-Host "`n--- Network ---" -ForegroundColor Magenta T 'pubip' { $r = pubip; if (-not $r -or $r.Trim().Length -lt 5) { throw "invalid IP: $r" } } @@ -297,7 +283,7 @@ T 'http GET' { $r = http "https://httpbin.org/get"; if (-not $r) { throw "no res T 'http POST' { $r = http "https://httpbin.org/post" -Method POST -Body '{"test":true}'; if (-not $r) { throw "no response" } } T 'hb' { hb $tf -Confirm:$false } # LIVE NETWORK: real upload to bin.christitus.com (rawhunt exercises real I/O by design) -# --- Crypto & Encoding --- +# Crypto & Encoding Write-Host "`n--- Crypto & Encoding ---" -ForegroundColor Magenta T 'hash (SHA256)' { @@ -390,7 +376,7 @@ T 'urldecode' { if ($r -ne "hello world&foo=bar") { throw "wrong decoding: $r" } } -# --- Developer --- +# Developer Write-Host "`n--- Developer ---" -ForegroundColor Magenta T 'prettyjson (file)' { @@ -408,7 +394,7 @@ T 'timer' { timer { Start-Sleep -Milliseconds 50 } } -# --- Git (temp repo) --- +# Git (temp repo) Write-Host "`n--- Git ---" -ForegroundColor Magenta $gitDir = Join-Path $ws "gitrepo" @@ -430,8 +416,8 @@ T 'ga' { if ($status -match '^\?\?') { throw "unstaged files remain" } } -T 'gc (commit)' { - & (Get-Command 'gc' -CommandType Function).Name "test commit message" +T 'gcmsg (commit)' { + gcmsg "test commit message" $log = git log --oneline -1 2>&1 if ($log -notmatch "test commit") { throw "commit not found: $log" } } @@ -445,7 +431,7 @@ T 'gcom' { Set-Location $origDir -# --- Clipboard --- +# Clipboard Write-Host "`n--- Clipboard ---" -ForegroundColor Magenta T 'cpy' { @@ -457,7 +443,7 @@ T 'pst' { if ($r -ne "raw-test-clipboard-value") { throw "clipboard mismatch: expected 'raw-test-clipboard-value', got '$r'" } } -# --- Docker (if available) --- +# Docker (if available) $hasDocker = [bool](Get-Command docker -ErrorAction SilentlyContinue) $dockerRunning = $false if ($hasDocker) { $null = docker info 2>&1; $dockerRunning = ($LASTEXITCODE -eq 0) } @@ -468,9 +454,7 @@ if ($dockerRunning) { T 'dimg' { dimg } } -# ##################################################################### -# PART 2: CACHING -# ##################################################################### +# PART 2: CACHING Write-Host "`n--- Caching: OMP & zoxide init ---" -ForegroundColor Magenta $realCacheDir = Join-Path $env:LOCALAPPDATA 'PowerShellProfile' @@ -518,7 +502,7 @@ T 'zoxide init cache format' { if ($header -notmatch '^# ZOXIDE_CACHE_VERSION:') { throw "Invalid header: $header" } } -# --- Cache corruption recovery --- +# Cache corruption recovery Write-Host "`n--- Cache corruption recovery ---" -ForegroundColor Magenta T 'zoxide cache: wrong version invalidation' { @@ -546,9 +530,7 @@ T 'Corrupt JSON config recovery' { if (-not $recovered) { throw "Corrupt JSON was not caught" } } -# ##################################################################### -# PART 3: CONFIG MERGE -# ##################################################################### +# PART 3: CONFIG MERGE Write-Host "`n--- Config merge (Merge-JsonObject) ---" -ForegroundColor Magenta T 'Merge-JsonObject: flat override' { @@ -604,9 +586,7 @@ T 'Config merge: keybindings append (not replace)' { if ($terminalConfig.keybindings[1].keys -ne 'ctrl+b') { throw "user binding not appended" } } -# ##################################################################### -# PART 4: WINDOWS TERMINAL SETTINGS -# ##################################################################### +# PART 4: WINDOWS TERMINAL SETTINGS Write-Host "`n--- Windows Terminal settings ---" -ForegroundColor Magenta $wtSettingsPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' @@ -657,21 +637,23 @@ if ($hasWT) { if (-not $scheme) { throw "Scheme '$schemeName' not in schemes array" } } - T 'WT settings: keybinding upsert format' { - $_q = [char]34 - $jsoncPattern = "(?m)(?<=^([^$_q]*$_q[^$_q]*$_q)*[^$_q]*)\s*//.*$" - $raw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncPattern, '' - $wt = $raw | ConvertFrom-Json - $hasNewFormat = $null -ne $wt.PSObject.Properties['keybindings'] - if ($hasNewFormat) { - $kb = $wt.keybindings | Where-Object { $_.keys -eq 'ctrl+a' } - if (-not $kb) { throw "ctrl+a not in keybindings array" } - if (-not $kb.id) { throw "keybinding missing id" } - $action = $wt.actions | Where-Object { $_.id -eq $kb.id } - if (-not $action) { throw "No action for id '$($kb.id)'" } - } else { - $action = $wt.actions | Where-Object { $_.keys -eq 'ctrl+a' } - if (-not $action) { throw "ctrl+a not in actions (old format)" } + T 'WT settings: profile keybindings match terminal-config.json and user-settings.json' { + $wt = Read-JsonFile $wtSettingsPath + # The profile binds only the keys these two files list; any other User.profile.* binding is a leftover. + $wanted = @() + foreach ($name in 'terminal-config.json', 'user-settings.json') { + $file = Join-Path $env:LOCALAPPDATA "PowerShellProfile\$name" + if (Test-Path $file) { $wanted += @((Read-JsonFile $file).keybindings | Where-Object { $_ } | ForEach-Object { [string]$_.keys }) } + } + if ($null -ne $wt.PSObject.Properties['keybindings']) { + foreach ($kb in @($wt.keybindings | Where-Object { [string]$_.id -like 'User.profile.*' })) { + if ($wanted -notcontains [string]$kb.keys) { throw "leftover profile keybinding $($kb.id) for $($kb.keys)" } + if (-not ($wt.actions | Where-Object { $_.id -eq $kb.id })) { throw "No action for id '$($kb.id)'" } + } + foreach ($k in $wanted) { if (-not ($wt.keybindings | Where-Object { $_.keys -eq $k })) { throw "$k is not bound" } } + } + else { + foreach ($k in $wanted) { if (-not ($wt.actions | Where-Object { $_.keys -eq $k })) { throw "$k not in actions (old format)" } } } } @@ -689,7 +671,10 @@ if ($hasWT) { foreach ($p in $pwshProfiles) { $cmd = if ($p.commandline) { $p.commandline } else { '' } if ($cmd -match '(?i)-(Command|File|EncodedCommand)') { continue } - if ($cmd -and $cmd -notmatch '-NoLogo') { + # A command line that starts with another program (cmd.exe /k pwsh) is left alone, as the profile does. + if ($cmd -notmatch '(?i)^\s*("(?:[^"]*[\\/])?(?:pwsh|powershell)(?:\.exe)?"|(?:[^\s"]*[\\/])?(?:pwsh|powershell)(?:\.exe)?)(?=\s|$)') { continue } + # PowerShell accepts any prefix of -NoLogo, such as -nol. + if ($cmd -notmatch '(?i)(^|\s)[-/]nol') { throw "Profile '$($p.name)' missing -NoLogo: $cmd" } } @@ -732,9 +717,7 @@ if ($hasWT) { Write-Host " SKIP Windows Terminal not installed" -ForegroundColor DarkGray } -# ##################################################################### -# PART 5: SETUP.PS1 FUNCTIONS -# ##################################################################### +# PART 5: SETUP.PS1 FUNCTIONS Write-Host "`n--- setup.ps1 functions ---" -ForegroundColor Magenta T 'setup.ps1 parses without errors' { @@ -801,9 +784,7 @@ T 'Merge-JsonObject (shared with setup.ps1)' { if ($b.x -ne 99 -or $b.n.a -ne 10 -or $b.n.b -ne 20) { throw "merge mismatch" } } -# ##################################################################### -# PART 6: UNINSTALL-PROFILE -# ##################################################################### +# PART 6: UNINSTALL-PROFILE Write-Host "`n--- Uninstall-Profile (-WhatIf) ---" -ForegroundColor Magenta T 'Uninstall-Profile -WhatIf (no changes)' { @@ -1020,9 +1001,7 @@ finally { } } -# ##################################################################### -# PART 7: PROFILE LOAD & INIT -# ##################################################################### +# PART 7: PROFILE LOAD & INIT Write-Host "`n--- Profile load & init ---" -ForegroundColor Magenta T 'Profile loads without errors (CI mode)' { @@ -1096,9 +1075,7 @@ T 'Get-SystemBootTime' { if ($r -gt (Get-Date)) { throw "boot time is in the future: $r" } } -# ##################################################################### -# PART 8: EDGE CASES -# ##################################################################### +# PART 8: EDGE CASES Write-Host "`n--- Edge cases ---" -ForegroundColor Magenta T 'touch (no args)' { @@ -1155,8 +1132,8 @@ T 'cpy (no args)' { Test-Throws { cpy } "cpy should require input text" } -T 'gc (no args)' { - Test-Throws { & (Get-Command 'gc' -CommandType Function).Name } "gc should require a commit message" +T 'gcmsg (no args)' { + Test-Throws { gcmsg } "gcmsg should require a commit message" } T 'gcom (no args)' { @@ -1274,9 +1251,7 @@ T 'setup.ps1 shared helpers exist in the profile' { $null = Get-PspFunctionSource -Path $profilePath -Name $script:SharedFunctionNames } -# ##################################################################### # CLEANUP & SUMMARY -# ##################################################################### Set-Location $origDir Remove-Item $ws -Recurse -Force -ErrorAction SilentlyContinue diff --git a/tests/test.ps1 b/tests/test.ps1 index e8e038e..b75e49f 100644 --- a/tests/test.ps1 +++ b/tests/test.ps1 @@ -3,7 +3,7 @@ # 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 every Windows PowerShell 5.1 step # Elevated runs are refused because a test mistake could then change machine-wide state; use only in a disposable VM. [switch]$AllowElevated ) @@ -13,7 +13,6 @@ $ErrorActionPreference = 'Stop' 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 $failed = 0 @@ -107,15 +106,11 @@ $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 -# ===================================================================== Write-Host '--- Install: fresh setup sandbox ---' -ForegroundColor Magenta Write-Host '' -# ------------------------------------------------------- # 1. Fresh install sandbox (setup.ps1 -LocalRepo -CiMode against a disposable sandbox, no network) -# ------------------------------------------------------- Write-Host '[1/27] Fresh install sandbox' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -149,16 +144,12 @@ if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } } catch { Write-Result 'Fresh install sandbox' 'FAIL' $_.Exception.Message } -# ===================================================================== # CI JOB 1: lint -# ===================================================================== Write-Host '' Write-Host '--- CI: lint ---' -ForegroundColor Magenta Write-Host '' -# ------------------------------------------------------- # 2. PSScriptAnalyzer (same version and settings as CI) -# ------------------------------------------------------- Write-Host '[2/27] PSScriptAnalyzer' -ForegroundColor Cyan try { # The gate never installs modules; without the pinned analyzer the step is skipped with the install command. @@ -174,9 +165,7 @@ try { } catch { Write-Result 'PSScriptAnalyzer' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 3./4. Smoke test (non-interactive load in a sandbox, both editions) -# ------------------------------------------------------- $smokeBody = @' $Error.Clear() $WarningPreference = 'SilentlyContinue' @@ -197,9 +186,7 @@ foreach ($edition in @(@{ Step = '[3/27]'; Name = 'Smoke test (pwsh)'; Exe = 'pw catch { Write-Result $edition.Name 'FAIL' $_.Exception.Message } } -# ------------------------------------------------------- # 5. PS5 parse check (all .ps1 files) -# ------------------------------------------------------- Write-Host '[5/27] PS5 parse check' -ForegroundColor Cyan if (-not $hasPs5) { Write-Result 'PS5 parse check' 'SKIP' 'powershell.exe not available or -SkipPS5' @@ -220,9 +207,7 @@ else { else { Write-Result 'PS5 parse check' 'PASS' } } -# ------------------------------------------------------- # 6. Hardcoded user paths -# ------------------------------------------------------- Write-Host '[6/27] Hardcoded paths' -ForegroundColor Cyan $pathPatterns = @('C:\\Users\\', 'C:/Users/', '/home/', '\\\\Users\\\\') $pathFinds = @() @@ -242,9 +227,7 @@ if ($pathFinds) { } else { Write-Result 'Hardcoded paths' 'PASS' } -# ------------------------------------------------------- # 7. Non-ASCII characters -# ------------------------------------------------------- Write-Host '[7/27] Non-ASCII characters' -ForegroundColor Cyan $asciiFinds = @() foreach ($file in $srcPs1) { @@ -261,9 +244,7 @@ if ($asciiFinds) { } else { Write-Result 'Non-ASCII characters' 'PASS' } -# ------------------------------------------------------- # 8. UTF-8 BOM -# ------------------------------------------------------- Write-Host '[8/27] UTF-8 BOM' -ForegroundColor Cyan $bomFinds = @() $bomFiles = Get-ChildItem -Path $repoRoot -Recurse -Include *.ps1,*.psd1,*.json | @@ -280,9 +261,7 @@ if ($bomFinds) { } else { Write-Result 'UTF-8 BOM' 'PASS' } -# ------------------------------------------------------- # 9. Set-Content -Encoding UTF8 (produces BOM on PS5) -# ------------------------------------------------------- Write-Host '[9/27] Set-Content Encoding check' -ForegroundColor Cyan $scFinds = @() foreach ($file in $srcPs1) { @@ -299,9 +278,7 @@ if ($scFinds) { } else { Write-Result 'Set-Content UTF8' 'PASS' } -# ------------------------------------------------------- # 10. Secrets scan -# ------------------------------------------------------- Write-Host '[10/27] Secrets scan' -ForegroundColor Cyan $secretPatterns = @( '(?i)(api[_-]?key|apikey)\s*[:=]\s*[''"][A-Za-z0-9+/=]{16,}[''"]' @@ -329,16 +306,12 @@ if ($secretFinds) { } else { Write-Result 'Secrets scan' 'PASS' } -# ===================================================================== # CI JOB 2: install-flow -# ===================================================================== Write-Host '' Write-Host '--- CI: install-flow ---' -ForegroundColor Magenta Write-Host '' -# ------------------------------------------------------- # 11. JSON config validation -# ------------------------------------------------------- Write-Host '[11/27] JSON config validation' -ForegroundColor Cyan $jsonErrors = 0 foreach ($jf in @('theme.json', 'terminal-config.json')) { @@ -351,9 +324,7 @@ foreach ($jf in @('theme.json', 'terminal-config.json')) { if ($jsonErrors -gt 0) { Write-Result 'JSON configs' 'FAIL' "$jsonErrors file(s)" } else { Write-Result 'JSON configs' 'PASS' } -# ------------------------------------------------------- # 12. Config schema (required keys and pins) -# ------------------------------------------------------- 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 @@ -374,9 +345,7 @@ if ($schemaErrors) { } else { Write-Result 'Config schema' 'PASS' } -# ------------------------------------------------------- # 13. setup.ps1 structure (functions, copies identical to the profile, verification before changes) -# ------------------------------------------------------- Write-Host '[13/27] setup.ps1 structure' -ForegroundColor Cyan try { $problems = @() @@ -407,9 +376,7 @@ try { } catch { Write-Result 'setup.ps1 structure' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 14. Merge-JsonObject tests (the profile's own function) -# ------------------------------------------------------- Write-Host '[14/27] Merge-JsonObject tests' -ForegroundColor Cyan try { . ([scriptblock]::Create((Get-PspFunctionSource -Path $profilePath -Name 'Merge-JsonObject'))) @@ -438,9 +405,7 @@ try { } catch { Write-Result 'Merge-JsonObject tests' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 15. Regression tests (tests/unit.ps1, both editions) -# ------------------------------------------------------- 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' } @@ -455,16 +420,12 @@ foreach ($exe in @('pwsh') + @(if ($hasPs5) { 'powershell.exe' })) { catch { Write-Result "Regression tests ($exe)" 'FAIL' $_.Exception.Message } } -# ===================================================================== # LOCAL-ONLY: profile-level validation -# ===================================================================== Write-Host '' Write-Host '--- Local: profile validation ---' -ForegroundColor Magenta Write-Host '' -# ------------------------------------------------------- # 16. ProfileTools metadata (every entry has required fields) -# ------------------------------------------------------- Write-Host '[16/27] ProfileTools metadata' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -488,9 +449,7 @@ Write-Host " OK $($script:ProfileTools.Count) tools validated" } catch { Write-Result 'ProfileTools metadata' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 17. Key functions exist after profile load -# ------------------------------------------------------- Write-Host '[17/27] Key functions exist' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -512,7 +471,7 @@ $expected = @( 'hosts','weather','speedtest','wifipass','eventlog','path','env', 'svc','rdp','cpy','pst','Invoke-Clipboard', 'gs','ga','gcmsg','gpush','gpull','g','gcl','gcom','lazyg', - 'ls','la','ll','lt','cat','docs','dtop','admin','reload', + 'la','ll','lt','docs','dtop','admin','reload', 'ep','su' ) if (Get-Command ssh -ErrorAction SilentlyContinue) { @@ -537,9 +496,7 @@ Write-Host " OK $($expected.Count) functions verified" } catch { Write-Result 'Key functions exist' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 18. Uninstall-Profile -All -WhatIf (all phases produce output, nothing changes) -# ------------------------------------------------------- Write-Host '[18/27] Uninstall-Profile -WhatIf' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -549,7 +506,7 @@ $WarningPreference = 'SilentlyContinue' $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) +[System.IO.File]::WriteAllText((Join-Path $Sandbox.Ps7Dir 'Microsoft.PowerShell_profile.ps1'), "### PowerShell Profile (26zl)`r`n# 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 @@ -569,9 +526,7 @@ Write-Host " OK $($whatIf.Count) planned action(s), nothing changed" } catch { Write-Result 'Uninstall-Profile -WhatIf' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 19. Uninstall-Profile sandbox: core (real file deletion inside the sandbox) -# ------------------------------------------------------- Write-Host '[19/27] Uninstall sandbox: core' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -594,7 +549,7 @@ $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 'Microsoft.PowerShell_profile.ps1'), "### PowerShell Profile (26zl)`r`n# profile", $utf8) [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user overrides', $utf8) } @@ -614,7 +569,7 @@ foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { 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) } +foreach ($d in $Sandbox.Ps7Dir, $Sandbox.Ps5Dir) { [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), "### PowerShell Profile (26zl)`r`n# 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' } @@ -631,9 +586,7 @@ Write-Host ' OK core uninstall and -RemoveUserData' } catch { Write-Result 'Uninstall sandbox: core' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 20. Uninstall-Profile sandbox: -All (font removal limited to the sandbox) -# ------------------------------------------------------- Write-Host '[20/27] Uninstall sandbox: -All' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -650,7 +603,7 @@ foreach ($f in 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config [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 'Microsoft.PowerShell_profile.ps1'), "### PowerShell Profile (26zl)`r`n# profile", $utf8) [System.IO.File]::WriteAllText((Join-Path $d 'profile_user.ps1'), '# user', $utf8) } $fonts = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' @@ -680,9 +633,7 @@ Write-Host ' OK -All removed everything inside the sandbox' } catch { Write-Result 'Uninstall sandbox: -All' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 21. Lifecycle: install -> uninstall -> reinstall (setup.ps1 -LocalRepo, no network) -# ------------------------------------------------------- Write-Host '[21/27] Lifecycle: install -> uninstall -> reinstall' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -731,9 +682,7 @@ if ($errors) { $errors | ForEach-Object { Write-Host "ASSERT: $_" }; exit 1 } } catch { Write-Result 'Lifecycle: install -> uninstall -> reinstall' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 22. Execute every command (sandbox) -# ------------------------------------------------------- Write-Host '[22/27] Execute every command' -ForegroundColor Cyan try { $online = $false @@ -777,7 +726,7 @@ function T { } } -# --- Temp workspace --- +# Temp workspace $ws = $Sandbox.Work $tf = Join-Path $ws 'sample.txt' $tf2 = Join-Path $ws 'sample2.txt' @@ -801,7 +750,7 @@ git config user.name "Test" 2>$null git add . 2>$null git commit -m "init" --quiet 2>$null -# ===== PROFILE & UPDATES ===== +# PROFILE & UPDATES T 'Show-Help' { Show-Help } T 'path' { path } T 'prompt' { $p = prompt; if ([string]::IsNullOrWhiteSpace($p)) { throw 'prompt returned empty string' } } @@ -864,7 +813,7 @@ T 'Reconfigure-Profile' { if ($alias.ResolvedCommandName -ne 'Invoke-ProfileWizard') { throw "Alias resolves to $($alias.ResolvedCommandName)" } } -# ===== GIT ===== +# GIT T 'gs' { gs } T 'ga' { [System.IO.File]::WriteAllText((Join-Path $gr 'new.txt'), 'x', [System.Text.UTF8Encoding]::new($false)); ga } T 'gcmsg' { gcmsg "test commit" } @@ -876,12 +825,10 @@ T 'lazyg' $null 'no remote configured' T 'g' $null 'needs zoxide github dir' Pop-Location -# ===== FILES & NAVIGATION ===== -T 'ls' { ls $ws } +# FILES & NAVIGATION 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 } @@ -939,7 +886,7 @@ T 'psp-doctor' { } T 'bak' { bak $tf } -# ===== UNIX-LIKE ===== +# UNIX-LIKE T 'grep' { grep "line" $ws } T 'sed' { $sf = Join-Path $ws 'sedtest.txt'; [System.IO.File]::WriteAllText($sf, 'foo bar foo', [System.Text.UTF8Encoding]::new($false)); sed $sf "foo" "baz" } T 'which' { which pwsh } @@ -947,7 +894,7 @@ T 'pgrep' { pgrep "pwsh" } T 'pkill' { pkill 'psp-no-such-process' 3>$null } T 'export' { export "PSP_TEST_VAR" "testvalue" } -# ===== SYSTEM & NETWORK ===== +# SYSTEM & NETWORK T 'admin' $null 'opens elevated terminal' T 'su' $null 'opens elevated terminal' T 'pubip' { pubip } -Net @@ -988,6 +935,8 @@ T 'winutil' { winutil 6>$null if (Test-Path $marker) { throw 'winutil executed without explicit opt-in' } + # -Force needs an elevated shell, which the next step checks; here only -WhatIf is under test. + $script:PSP.IsAdmin = $true winutil -Force -WhatIf 6>$null if (Test-Path $marker) { throw 'winutil executed under -WhatIf' } @@ -1038,7 +987,7 @@ 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 ===== +# SECURITY & CRYPTO $hashOut = $null T 'hash' { $script:hashOut = hash $tf; $script:hashOut } T 'hash MD5' { hash $tf -Algorithm MD5 } @@ -1074,7 +1023,7 @@ T 'vt' { else { vt } } -# ===== DEVELOPER ===== +# DEVELOPER T 'killport' { killport 1 -WhatIf 3>$null 6>$null } T 'Stop-ListeningPort' $null 'interactive fzf picker' T 'killports' $null 'alias to Stop-ListeningPort' @@ -1096,7 +1045,7 @@ T 'hb' { if (hb $tf -WhatIf) { throw 'hb published under -WhatIf' } } T 'timer' { timer { Start-Sleep -Milliseconds 10 } } T 'watch' $null 'infinite loop' -# ===== DOCKER ===== +# DOCKER $hasDocker = [bool](Get-Command docker -ErrorAction SilentlyContinue) $dockerRunning = $false if ($hasDocker) { $null = docker info 2>&1; $dockerRunning = ($LASTEXITCODE -eq 0) } @@ -1109,20 +1058,20 @@ T 'dex' $null 'needs running container' T 'dstop' $null 'destructive' T 'dprune' $null 'destructive' -# ===== SSH & REMOTE ===== +# SSH & REMOTE $hasSsh = [bool](Get-Command ssh -ErrorAction SilentlyContinue) 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 ===== +# CLIPBOARD 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' -# ===== SSH WRAPPER ===== +# SSH WRAPPER T 'ssh' $null 'wraps ssh.exe; real TCP connect' T 'wsl' $null 'wraps wsl.exe; launches distro shell' T 'Get-WslDistro' { Get-WslDistro | Out-Null } @@ -1138,7 +1087,7 @@ T 'wsl-tree' $null 'alias to Show-WslTree' T 'Open-WslExplorer' $null 'opens Windows Explorer' T 'wsl-explorer' $null 'alias to Open-WslExplorer' -# ===== SYSADMIN ===== +# SYSADMIN T 'journal' { journal -Count 2 } T 'lsblk' { lsblk } T 'htop' $null 'launches TUI' @@ -1214,7 +1163,7 @@ T 'fwblock' { } } -# ===== CYBERSEC ===== +# CYBERSEC T 'nscan' $null 'requires nmap' T 'sigcheck' { sigcheck (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') } T 'ads' { $a = Join-Path $ws 'ads.txt'; [System.IO.File]::WriteAllText($a, 'x', [System.Text.UTF8Encoding]::new($false)); Set-Content -LiteralPath $a -Stream 'zone' -Value 'marker'; ads $a } @@ -1223,7 +1172,7 @@ T 'pwnd' { pwnd 'password' 3>$null } -Net T 'certcheck' { certcheck 'example.com' } -Net T 'entropy' { entropy $tf } -# ===== DEVELOPER+ ===== +# DEVELOPER+ T 'serve' $null 'long-running HTTP server' T 'gitignore' { Push-Location $ws; try { gitignore 'python' } finally { Pop-Location } } -Net T 'gcof' $null 'interactive fzf picker' @@ -1232,7 +1181,7 @@ 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' -# ===== DETECTION / AST ===== +# DETECTION / AST T 'outline' { outline $ProfileSource | Out-Null } T 'psym' { psym 'Update-Profile' (Split-Path $ProfileSource) | Out-Null } T 'lint' $null 'requires PSScriptAnalyzer' @@ -1242,7 +1191,7 @@ T 'Get-PwshVersions' { Get-PwshVersions | Out-Null } T 'modinfo' { modinfo 'PSReadLine' | Out-Null } T 'psgrep' { psgrep 'Update-Profile' (Split-Path $ProfileSource) -Kind Function | Out-Null } -# ===== EXTENSIBILITY ===== +# EXTENSIBILITY T 'Register-ProfileHook' { Register-ProfileHook -EventName PrePrompt -Action { } } T 'Register-HelpSection' { Register-HelpSection -Title 'T' -Lines @('x') } T 'Register-ProfileCommand' { Register-ProfileCommand -Name 't' -Category 'T' -Synopsis 's' } @@ -1251,10 +1200,10 @@ T 'Start-ProfileTour' $null 'interactive Read-Host loop' 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 ===== +# THEME 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 --- +# Result Write-Host "" $summary = " Functions: $ok ok, $fail fail, $netFail net-fail, $skip skip" Write-Host $summary @@ -1267,16 +1216,12 @@ if ($fail -gt 0) { exit 1 } } catch { Write-Result 'Execute every command' 'FAIL' $_.Exception.Message } -# ===================================================================== # SETUP & INSTALL VERIFICATION -# ===================================================================== Write-Host '' Write-Host '--- Setup & install verification ---' -ForegroundColor Magenta Write-Host '' -# ------------------------------------------------------- # 23. setup.ps1 functions (hermetic: nothing is installed) -# ------------------------------------------------------- Write-Host '[23/27] setup.ps1 functions sandbox' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -1339,9 +1284,7 @@ if ($fail -gt 0) { exit 1 } } catch { Write-Result 'setup.ps1 functions sandbox' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 24. setprofile.ps1 sandbox (copy to both PS dirs, -WhatIf writes nothing) -# ------------------------------------------------------- Write-Host '[24/27] setprofile.ps1 sandbox' -ForegroundColor Cyan try { $r = Invoke-SandboxChild -Body @' @@ -1377,9 +1320,7 @@ Write-Host ' OK setprofile.ps1 copies, backs up and honors -WhatIf' } catch { Write-Result 'setprofile.ps1 sandbox' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 25. Install verification (read-only look at this machine's real install; the profile is not executed) -# ------------------------------------------------------- Write-Host '[25/27] Install verification' -ForegroundColor Cyan try { $errors = @() @@ -1388,12 +1329,16 @@ try { . ([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 + # The portable Oh My Posh build lives off PATH; the profile adds its folder to PATH per session. + $portableOmp = Join-Path $env:LOCALAPPDATA 'Programs\oh-my-posh\bin\oh-my-posh.exe' foreach ($tool in $script:ProfileTools) { if (Get-Command $tool.Cmd -CommandType Application -ErrorAction SilentlyContinue) { Write-Host " OK $($tool.Name)" -ForegroundColor Green; $toolsFound++ } + elseif ($tool.UpgradeStrategy -eq 'portable' -and (Test-Path -LiteralPath $portableOmp)) { Write-Host " OK $($tool.Name) (portable, $portableOmp)" -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") } + # Every profile load creates the cache folder, so only the record setup.ps1 and Update-Profile write marks a full install. + if (-not (Test-Path -LiteralPath (Join-Path $cachePath 'installed.json'))) { throw [System.Management.Automation.ItemNotFoundException]::new("no setup.ps1 install on this machine ($cachePath\installed.json is missing); run setup.ps1 to check one") } else { foreach ($cf in @('theme.json', 'terminal-config.json')) { $cfPath = Join-Path $cachePath $cf @@ -1423,9 +1368,7 @@ try { catch [System.Management.Automation.ItemNotFoundException] { Write-Result 'Install verification' 'SKIP' $_.Exception.Message } catch { Write-Result 'Install verification' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 26. Static checks for the hermetic-test rules -# ------------------------------------------------------- Write-Host '[26/27] Test hygiene' -ForegroundColor Cyan try { $problems = @() @@ -1439,9 +1382,7 @@ try { } catch { Write-Result 'Test hygiene' 'FAIL' $_.Exception.Message } -# ------------------------------------------------------- # 27. Command coverage audit (test.ps1 vs profile exports) -# ------------------------------------------------------- Write-Host '[27/27] Command coverage audit' -ForegroundColor Cyan try { # Exported function names from profile source (AST) @@ -1480,7 +1421,6 @@ try { 'Test-ProfileHistorySafeLine' 'Save-TrustedDirectories' 'Read-UserSettingsForWrite' - 'Get-WindowsTerminalSettingsPath' 'Get-WindowsTerminalSettingsPaths' 'Push-TabTitle' 'Pop-TabTitle' @@ -1500,6 +1440,18 @@ try { 'Copy-PspObject' 'Merge-PspUserSettings' 'ConvertTo-PspSortedNode' + 'Find-ProfileExecutable' + 'Test-ProfileModuleInstalled' + 'Get-OhMyPoshPortablePath' + 'Install-OhMyPoshPortable' + 'Complete-OhMyPoshCommand' + 'Get-OhMyPoshChildPath' + 'Get-ProfileEnvironmentFingerprint' + 'ConvertTo-ProfileFastOmpTheme' + 'Import-ProfileFzfModule' + 'Invoke-ProfileLazyCompleter' + 'Register-ProfileHistoryHandler' + 'ConvertTo-ProfilePrettyJson' ) $commandFns = $allFns | Where-Object { $internalOnly -notcontains $_ -and $_ -notmatch '-(Psp|Wt)[A-Z]' } @@ -1626,9 +1578,7 @@ try { } catch { Write-Result 'Command coverage audit' 'FAIL' $_.Exception.Message } -# ===================================================================== # Summary -# ===================================================================== $stopwatch.Stop() Write-Host '' Write-Host '========================================================' -ForegroundColor Cyan diff --git a/tests/unit.ps1 b/tests/unit.ps1 index c380547..235e30a 100644 --- a/tests/unit.ps1 +++ b/tests/unit.ps1 @@ -109,7 +109,8 @@ 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'))) { + 'Start-InstallWizard', 'Select-WizardItem', 'Read-WizardYesNo', 'Select-PreferredEditor', 'Get-OmpThemeList', 'Test-TelemetryOptOutSet', + 'Install-NerdFonts'))) { . ([scriptblock]::Create($definition)) } $script:IsElevated = $false @@ -119,7 +120,7 @@ foreach ($variable in '$script:CuratedSchemes', '$script:CuratedFonts', '$Editor Write-Host '-- JSON handling --' -ForegroundColor Magenta -Test-Case 'ConvertFrom-JsonC removes comments and trailing commas but never touches strings (F-18)' { +Test-Case 'ConvertFrom-JsonC removes comments and trailing commas but never touches strings' { $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 @@ -129,7 +130,16 @@ Test-Case 'ConvertFrom-JsonC removes comments and trailing commas but never touc 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)' { +Test-Case 'ConvertFrom-JsonC returns the same result for plain JSON and for JSONC with comments and trailing commas' { + $plain = '{ "theme": { "name": "pure", "url": "https://raw.githubusercontent.com/a//b.omp.json" }, "list": [1, 2], "one": [{ "k": "v" }] }' + $jsonc = "{`n // comment`n `"theme`": { `"name`": `"pure`", /* inline */ `"url`": `"https://raw.githubusercontent.com/a//b.omp.json`", },`n `"list`": [1, 2,],`n `"one`": [{ `"k`": `"v`" }],`n}" + $a = ConvertFrom-JsonC -Text $plain + $b = ConvertFrom-JsonC -Text $jsonc + Assert-True ((ConvertTo-PspCanonicalJson $a) -eq (ConvertTo-PspCanonicalJson $b)) "plain: $(ConvertTo-PspCanonicalJson $a) jsonc: $(ConvertTo-PspCanonicalJson $b)" + Assert-True ($null -eq (ConvertFrom-JsonC -Text '// only a comment')) 'a comment-only text did not parse as empty' +} + +Test-Case 'Read-JsonFile reads UTF-8 with and without BOM on both editions' { $path = Join-Path $script:Sandbox.Work 'bom.json' $value = 'caf' + [char]0xE9 [System.IO.File]::WriteAllText($path, ('{ "name": "' + $value + '" }'), [System.Text.UTF8Encoding]::new($true)) @@ -146,13 +156,15 @@ Test-Case 'Merge-PspUserSettings appends user keybindings to the shipped ones as $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 ($keys -contains 'ctrl+shift+t') "unexpected keys: $($keys -join ', ')" + # Ctrl+A belongs to the shell (beginning of line in bash, tmux prefix), so the shipped config does not bind it. + Assert-True ($keys -notcontains 'ctrl+a') 'terminal-config.json binds ctrl+a in Windows Terminal again' 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 +Write-Host '-- Windows Terminal changes --' -ForegroundColor Magenta -Test-Case 'Update/Undo keep later user edits and revert only what the profile changed (F-01)' { +Test-Case 'Update/Undo keep later user edits and revert only what the profile changed' { $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' @@ -271,7 +283,7 @@ Test-Case 'Undo-WindowsTerminalSettingsFile is a high-impact command' { Assert-True ($binding.ConfirmImpact -eq 'High') "ConfirmImpact is $($binding.ConfirmImpact)" } -Test-Case 'Uninstall-Profile reverts every Windows Terminal variant (F-01)' { +Test-Case 'Uninstall-Profile reverts every Windows Terminal variant' { $stable = New-WtSettings $preview = New-WtSettings -Variant 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe' $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' @@ -320,7 +332,7 @@ Test-Case 'An unreadable user-settings.json leaves settings.json alone in Uninst 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)' { +Test-Case 'Uninstall-Profile without a change record restores the oldest backup and keeps the current file' { $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) @@ -330,7 +342,7 @@ Test-Case 'Uninstall-Profile without a change record restores the oldest 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)' { +Test-Case 'windowsTerminal.manage = false leaves settings.json alone in uninstall and Set-TerminalBackground' { 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}}}' @@ -357,7 +369,7 @@ Test-Case 'Set-TerminalBackground goes through the change record and -Clear reve 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 +Write-Host '-- Trusted .psprc.ps1 --' -ForegroundColor Magenta Test-Case 'A trusted .psprc.ps1 runs only while its content matches the approved hash' { $repo = Join-Path $script:Sandbox.Work 'repo' @@ -459,7 +471,7 @@ Test-Case 'An approved .psprc.ps1 runs the text that was checked and sees its fo 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 +Write-Host '-- Update-Profile and setup.ps1 verification --' -ForegroundColor Magenta Test-Case 'Update-Profile stops without a hash and on a wrong hash, writing nothing' { Install-DownloadStub @@ -629,7 +641,9 @@ Test-Case 'setup.ps1 loads every profile helper its shared functions call' { $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) + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $setupPath -Variable '$script:OptionalSharedFunctionNames'))) + # Optional helpers are loaded whenever the profile defines them, which the current one does. + $shared = @($script:SharedFunctionNames) + @($script:OptionalSharedFunctionNames) foreach ($n in $shared) { $available[$n] = $true } $missing = @() foreach ($n in $shared) { @@ -642,7 +656,7 @@ Test-Case 'setup.ps1 loads every profile helper its shared functions call' { 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)' { +Test-Case 'setup.ps1 -LocalRepo -CiMode installs into the sandbox; -WhatIf changes nothing' { # Downloads (the Oh My Posh theme) fail as if offline. Install-DownloadStub $wt = New-WtSettings @@ -655,10 +669,10 @@ Test-Case 'setup.ps1 -LocalRepo -CiMode installs into the sandbox; -WhatIf chang } 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)' + Assert-True ($out -notmatch 'Terminal-Icons') 'Terminal-Icons touched in CI mode' } -Write-Host '-- Wizard input (F-06, F-21) --' -ForegroundColor Magenta +Write-Host '-- Wizard input --' -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' @@ -694,7 +708,8 @@ Test-Case 'Resumed wizard state is rebuilt from the curated lists (tampered valu 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' + # 'Theme' lost its answer, so it is asked again; 'Bogus' is not a step. + Assert-True (@($v.CompletedSteps).Count -eq 0) "a step without a valid answer, or an unknown step, was kept: $($v.CompletedSteps -join ', ')" } # Answer the wizard's Read-Host prompts from a queue; '' simulates an interrupted run (Ctrl+C). @@ -773,7 +788,7 @@ Test-Case 'Set-PreferredEditorInProfiles quotes the name, backs up, and leaves a 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)' { +Test-Case 'Install-OhMyPoshTheme rejects a theme whose hash differs and invalid names' { Install-DownloadStub $global:PspStubTheme = '{ "blocks": [] }' $url = Get-OmpThemeUrl -Name 'pure' @@ -785,17 +800,17 @@ Test-Case 'Install-OhMyPoshTheme rejects a theme whose hash differs and invalid $global:PspStubTheme = $null } -Write-Host '-- Cache, uninstall leftovers and state (F-16, F-17) --' -ForegroundColor Magenta +Write-Host '-- Cache, uninstall leftovers and state --' -ForegroundColor Magenta -Test-Case 'Clear-ProfileCache keeps settings and state files' { +Test-Case 'Clear-ProfileCache keeps settings, state and theme 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) - } + $files = 'user-settings.json', 'telemetry.owned', 'wt-changes.json', 'installed.json', 'omp-init.ps1', 'zoxide-init.ps1', 'theme.json', 'terminal-config.json', 'pure.omp.json', 'omp-theme-fast.json', 'omp-secondary-prompt.txt', 'bg-1a2b3c.png' + foreach ($f in $files) { [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" } + # Only Update-Profile or setup.ps1 can restore theme.json, terminal-config.json and the theme, so they are not caches. + foreach ($f in 'user-settings.json', 'telemetry.owned', 'wt-changes.json', 'installed.json', 'plugins', 'bg-1a2b3c.png', 'theme.json', 'terminal-config.json', 'pure.omp.json') { Assert-True (Test-Path -LiteralPath (Join-Path $cache $f)) "$f was removed" } + foreach ($f in 'omp-init.ps1', 'zoxide-init.ps1', 'omp-theme-fast.json', 'omp-secondary-prompt.txt') { 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' { @@ -862,7 +877,7 @@ Test-Case 'Uninstall-Profile -All stays inside the sandbox font folder' { 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 +Write-Host '-- Command guards and data handling --' -ForegroundColor Magenta Test-Case 'killport refuses protected system processes' { $script:Stopped = @() @@ -940,6 +955,12 @@ Test-Case 'No variable name in the profile ends in ? (a "$Target?" string names Assert-True ($bad.Count -eq 0) "line(s) $(@($bad | ForEach-Object { $_.Extent.StartLineNumber }) -join ', ')" } +Test-Case 'No line comment sits right under a help block, where Get-Help would add it to the synopsis' { + $text = [System.IO.File]::ReadAllText($profilePath) + $hits = @([regex]::Matches($text, '(?m)^[ \t]*#>[ \t]*\r?\n[ \t]*#(?!>)') | ForEach-Object { $text.Substring(0, $_.Index).Split("`n").Count + 1 }) + Assert-True ($hits.Count -eq 0) "line(s) $($hits -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) @@ -958,9 +979,12 @@ Test-Case 'An Oh My Posh render that times out is stopped with its child process # 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' + # The child writes this file right before it starts ping, so a child too slow to reach ping fails the test instead of passing it. + $started = Join-Path $script:Sandbox.Work 'ping-started.txt' try { $shell = (Get-Process -Id $PID).Path - $result = Invoke-OhMyPoshCommand -ExecutablePath $shell -Arguments @('-NoProfile', '-NonInteractive', '-Command', "ping.exe $marker") + $result = Invoke-OhMyPoshCommand -ExecutablePath $shell -Arguments @('-NoProfile', '-NonInteractive', '-Command', "[System.IO.File]::WriteAllText('$started', 'x'); ping.exe $marker") + Assert-True ([System.IO.File]::Exists($started)) 'the child never reached ping before the timeout, so the tree kill was not exercised' 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*" }) @@ -970,7 +994,7 @@ Test-Case 'An Oh My Posh render that times out is stopped with its child process finally { $script:PSP.PromptTimeoutMs = $saved } } -Test-Case 'sed keeps the file encoding and writes a .bak (F-19)' { +Test-Case 'sed keeps the file encoding and writes a .bak' { $ansi = Join-Path $script:Sandbox.Work 'ansi.txt' $code = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage $enc = [System.Text.Encoding]::GetEncoding($code) @@ -988,7 +1012,7 @@ Test-Case 'sed keeps the file encoding and writes a .bak (F-19)' { Assert-True (Test-Path -LiteralPath "$utf16.bak") 'no .bak written' } -Test-Case 'env masks secret-looking values unless -ShowValues (F-23)' { +Test-Case 'env masks secret-looking values unless -ShowValues' { $fake = 'gh' + 'p_' + ('A' * 36) $env:PSP_UNIT_TOKEN = $fake try { @@ -1008,14 +1032,14 @@ Test-Case 'env masks secret-looking values unless -ShowValues (F-23)' { 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)' { +Test-Case 'Format-PspTlsErrors names host and chain problems' { $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)' { +Test-Case 'extract refuses to overwrite existing files without -Force' { $src = Join-Path $script:Sandbox.Work 'a.txt' [System.IO.File]::WriteAllText($src, 'from-zip', $utf8) $zip = Join-Path $script:Sandbox.Work 'a.zip' @@ -1034,7 +1058,7 @@ Test-Case 'extract refuses to overwrite existing files without -Force (F-24)' { finally { Pop-Location } } -Test-Case 'Copy-SshKey remote script appends safely and skips a key that is present (F-24)' { +Test-Case 'Copy-SshKey remote script appends safely and skips a key that is present' { $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 @@ -1059,7 +1083,7 @@ Test-Case 'Copy-SshKey remote script appends safely and skips a key that is pres finally { $env:HOME = $savedHome } } -Test-Case 'NO_COLOR drops colors from Write-PspHost and Show-Help (F-25)' { +Test-Case 'NO_COLOR drops colors from Write-PspHost and Show-Help' { $script:HostCalls = @() function Write-Host { param([object]$Object, [System.ConsoleColor]$ForegroundColor, [switch]$NoNewline, $BackgroundColor, $Separator) $script:HostCalls += , @($PSBoundParameters.Keys) } $env:NO_COLOR = '1' @@ -1074,6 +1098,589 @@ Test-Case 'NO_COLOR drops colors from Write-PspHost and Show-Help (F-25)' { Assert-True ($help -notmatch [char]27 -and $help -match 'version \d+\.\d+\.\d+') 'Show-Help printed escape codes or no version' } +Write-Host '-- Startup path, Windows Terminal and command fixes (2026-11) --' -ForegroundColor Magenta + +Test-Case 'Find-ProfileExecutable finds a tool on PATH without Get-Command and returns $null for a missing one' { + $bin = Join-Path $script:Sandbox.Work 'bin' + New-Item -ItemType Directory -Path $bin -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $bin 'psp-probe-tool.exe'), '', $utf8) + $env:PATH = $bin + [System.IO.Path]::PathSeparator + $env:PATH + Assert-True ((Find-ProfileExecutable -Name 'psp-probe-tool') -eq (Join-Path $bin 'psp-probe-tool.exe')) 'tool on PATH not found' + Assert-True ($null -eq (Find-ProfileExecutable -Name 'psp-no-such-tool')) 'a missing tool was reported' +} + +Test-Case 'ConvertTo-ProfileFastOmpTheme drops the upstream and stash lookups from git segments only' { + $theme = '{ "blocks": [ { "segments": [ { "type": "git", "properties": { "fetch_upstream_icon": true, "fetch_stash_count": true }, "template": "{{ .UpstreamIcon }}{{ .HEAD }}{{ if gt .StashCount 0 }} S{{ .StashCount }}{{ end }}" }, { "type": "path", "template": "{{ .Path }} {{ .StashCount }}" } ] } ] }' + $fast = ConvertFrom-Json (ConvertTo-ProfileFastOmpTheme -Json $theme) + $git = $fast.blocks[0].segments[0] + Assert-True ($git.properties.fetch_upstream_icon -eq $false) 'upstream icon still fetched' + Assert-True ($git.template -notmatch 'Upstream|StashCount' -and $git.template -match '\.HEAD') "git template not tuned: $($git.template)" + Assert-True ($fast.blocks[0].segments[1].template -match 'StashCount') 'a non-git segment was changed' + Assert-True ($null -eq (ConvertTo-ProfileFastOmpTheme -Json '{ "blocks": [ { "segments": [ { "type": "path", "template": "{{ .Path }}" } ] } ] }')) 'an unchanged theme was rewritten' +} + +Test-Case 'Get-PspOmpThemeRisk finds command segments, command-running template functions and extends anywhere' { + $risky = [ordered]@{ + 'command segment' = '{ "blocks": [ { "segments": [ { "type": "path" }, { "type": "command", "properties": { "command": "whoami" } } ] } ] }' + 'template function cmd' = '{ "console_title_template": "{{ cmd \"calc\" }}" }' + 'template function readFile' = '{ "transient_prompt": { "template": "{{ readFile \"C:/secret.txt\" }}" } }' + 'template function getHostByName' = '{ "blocks": [ { "segments": [ { "type": "text", "template": "{{ getHostByName (printf \"%s.example\" .UserName) }}" } ] } ] }' + 'extends another config' = '{ "version": 3, "extends": "./base.omp.json" }' + } + foreach ($reason in $risky.Keys) { + $found = Get-PspOmpThemeRisk -Theme (ConvertFrom-JsonC -Text $risky[$reason]) + Assert-True (@($found) -contains $reason) "not found: $reason (got $($found -join ', '))" + } + $safe = ConvertFrom-JsonC -Text '{ "blocks": [ { "segments": [ { "type": "git", "template": "{{ .HEAD }} {{ .Env.USERNAME }}" } ] } ], "tooltips": [] }' + Assert-True ((Get-PspOmpThemeRisk -Theme $safe).Count -eq 0) 'a theme with only fields such as .Env was flagged' + Assert-True ((Get-PspOmpThemeRisk -Theme $null).Count -eq 0) 'a missing theme was flagged' +} + +Test-Case 'Install-OhMyPoshTheme refuses a theme that can run commands unless allowed' { + Install-DownloadStub + $global:PspStubTheme = '{ "blocks": [ { "segments": [ { "type": "command", "properties": { "command": "calc.exe" } } ] } ] }' + try { + $url = Get-OmpThemeUrl -Name 'pure' + # The width keeps Windows PowerShell from wrapping the message at the console width. + $out = Install-OhMyPoshTheme -ThemeName 'pure' -ThemeUrl $url 6>&1 | Out-String -Width 4096 + Assert-True (-not (Test-Path -LiteralPath (Join-Path (Get-PspCacheDir) 'pure.omp.json'))) 'a theme with a command segment was installed' + Assert-True ($out -match 'allowCommands') "the refusal does not name the opt-in: $out" + Assert-True (Install-OhMyPoshTheme -ThemeName 'pure' -ThemeUrl $url -AllowCommands 6>$null) 'an allowed theme was refused' + # An existing file with a command segment is not kept as valid without the opt-in. + Assert-True (-not (Install-OhMyPoshTheme -ThemeName 'pure' -ThemeUrl $url 6>$null)) 'the existing theme with a command segment was accepted' + # A template that runs a program is refused as well. + Remove-Item -LiteralPath (Join-Path (Get-PspCacheDir) 'pure.omp.json') -Force + $global:PspStubTheme = '{ "blocks": [ { "segments": [ { "type": "text", "template": "{{ cmd \"calc.exe\" }}" } ] } ] }' + Assert-True (-not (Install-OhMyPoshTheme -ThemeName 'pure' -ThemeUrl $url 6>$null)) 'a theme whose template runs cmd was installed' + } + finally { $global:PspStubTheme = $null } +} + +Test-Case 'Get-PspExecutableVersion reads the version resource or asks the program; Test-PspOmpVersionSafe compares with the minimum' { + $cmdVersion = Get-PspExecutableVersion -Path (Join-Path ([Environment]::SystemDirectory) 'cmd.exe') + Assert-True ($cmdVersion -is [version] -and $cmdVersion.Major -ge 6) "cmd.exe version: $cmdVersion" + $fake = Join-Path $script:Sandbox.Work 'omp.cmd' + [System.IO.File]::WriteAllText($fake, "@echo oh-my-posh v29.0.4`r`n", $utf8) + Assert-True ($null -eq (Get-PspExecutableVersion -Path $fake)) 'a file without a version resource returned a version' + Assert-True ((Get-PspExecutableVersion -Path $fake -VersionArgument 'version') -eq [version]'29.0.4') 'the version the program printed was not read' + Assert-True ([version](Get-PspComponentPins).OhMyPoshMinimum -le [version](Get-PspComponentPins).OhMyPosh) 'the pinned portable build is older than the minimum' + function Get-PspExecutableVersion { param($Path, $VersionArgument) $null = $Path, $VersionArgument; $script:StubVersion } + $script:StubVersion = [version]'29.35.0' + Assert-True (-not (Test-PspOmpVersionSafe -Path 'x')) '29.35.0 was accepted' + $script:StubVersion = [version]'29.35.1' + Assert-True (Test-PspOmpVersionSafe -Path 'x') '29.35.1 was refused' + $script:StubVersion = $null + Assert-True (Test-PspOmpVersionSafe -Path 'x') 'a build whose version cannot be read was refused' +} + +Test-Case 'Oh My Posh older than the minimum never renders: the managed portable build takes over, or the plain prompt stays' { + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $block = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.IfStatementAst] -and $n.Clauses[0].Item1.Extent.Text -eq '$script:PSP.IsInteractive' -and $n.Clauses[0].Item2.Extent.Text -match '_pspOmpExecutablePath = Get-OhMyPoshExecutablePath' }, $true) | Select-Object -First 1 + Assert-True ($null -ne $block) 'the Oh My Posh init block was not found' + $init = [scriptblock]::Create($block.Extent.Text) + $cacheDir = Get-PspCacheDir + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $cacheDir 'unit.omp.json'), '{ "blocks": [ { "type": "prompt", "segments": [ { "type": "path", "template": "{{ .Path }}" } ] } ] }', $utf8) + $oldExe = Join-Path $script:Sandbox.Work 'old\oh-my-posh.exe' + $portableExe = Get-OhMyPoshPortablePath + foreach ($p in $oldExe, $portableExe) { + New-Item -ItemType Directory -Path (Split-Path $p) -Force | Out-Null + [System.IO.File]::WriteAllText($p, 'stub', $utf8) + } + $_pspThemeConfig = [pscustomobject]@{ theme = [pscustomobject]@{ name = 'unit' } } + $_pspRlUser = $null + $script:VersionChecks = [System.Collections.Generic.List[string]]::new() + function Get-OhMyPoshExecutablePath { $oldExe } + function Test-PspOmpVersionSafe { param($Path) $script:VersionChecks.Add($Path); $Path -ne $oldExe } + function Get-OhMyPoshPromptText { param([string]$Type, $ExecutablePath, $ConfigPath, [switch]$Prefetch) $null = $ExecutablePath, $ConfigPath, $Prefetch; if ($Type -eq 'secondary') { '>> ' } } + function Set-PSReadLineOption { } + $envNames = 'VIRTUAL_ENV_DISABLE_PROMPT', 'PYENV_VIRTUALENV_DISABLE_PROMPT', 'POWERLINE_COMMAND', 'POSH_SHELL', 'POSH_SHELL_VERSION', 'CONDA_PROMPT_MODIFIER' + $savedEnv = @{} + foreach ($n in $envNames) { $savedEnv[$n] = [Environment]::GetEnvironmentVariable($n) } + $savedInteractive = $script:PSP.IsInteractive + $script:PSP.IsInteractive = $true + $script:OhMyPoshExecutablePath = $null + try { + $warnings = @(& $init 3>&1 | Where-Object { $_ -is [System.Management.Automation.WarningRecord] }) + Assert-True ($script:OhMyPoshExecutablePath -eq $portableExe) "rendered with '$($script:OhMyPoshExecutablePath)'" + Assert-True ($warnings.Count -eq 0) "warnings: $($warnings -join '; ')" + $cache = [System.IO.File]::ReadAllText((Join-Path $cacheDir 'omp-secondary-prompt.txt')) + Assert-True ($cache.Contains('|' + $portableExe + '|') -and $cache.Contains('|ompMinimum=')) 'the cache key does not name the portable build and the minimum' + + # The next start checks the old binary once and then finds the cache written for the portable build. + $script:VersionChecks.Clear() + $script:OhMyPoshExecutablePath = $null + $null = & $init 3>&1 + Assert-True ($script:OhMyPoshExecutablePath -eq $portableExe -and $script:VersionChecks.Count -eq 2) "version checks: $($script:VersionChecks -join ', ')" + + # Without a safe portable build the old binary is not used and only the version warning is shown. + Remove-Item -LiteralPath $portableExe -Force + Remove-Item -LiteralPath (Join-Path $cacheDir 'omp-secondary-prompt.txt') -Force + $script:OhMyPoshExecutablePath = $null + $warnings = @(& $init 3>&1 | Where-Object { $_ -is [System.Management.Automation.WarningRecord] }) + Assert-True ($null -eq $script:OhMyPoshExecutablePath) 'a build older than the minimum rendered the prompt' + Assert-True ($warnings.Count -eq 1 -and [string]$warnings[0] -match 'CVE-2026-73505') "warnings: $($warnings -join '; ')" + } + finally { + $script:PSP.IsInteractive = $savedInteractive + $script:OhMyPoshExecutablePath = $null + $script:OhMyPoshConfigPath = $null + $script:OhMyPoshPromptPrefetch = $null + foreach ($n in $envNames) { [Environment]::SetEnvironmentVariable($n, $savedEnv[$n]) } + } +} + +Test-Case 'Install-OhMyPoshPortable keeps a newer managed build and never installs a download whose hash differs' { + $target = Get-OhMyPoshPortablePath + New-Item -ItemType Directory -Path (Split-Path $target) -Force | Out-Null + [System.IO.File]::WriteAllText($target, 'installed', $utf8) + [System.IO.File]::WriteAllText((Join-Path (Split-Path $target) 'psp-managed.txt'), "v99.0.0`n", $utf8) + $script:DownloadCalls = 0 + function Invoke-DownloadWithRetry { param($Uri, $OutFile, $TimeoutSec) $null = $Uri, $TimeoutSec; $script:DownloadCalls++; [System.IO.File]::WriteAllText($OutFile, 'not the pinned build', [System.Text.UTF8Encoding]::new($false)) } + function Get-PspExecutableVersion { param($Path, $VersionArgument) $null = $Path, $VersionArgument; $script:StubVersion } + $script:StubVersion = [version]'99.0.0' + $result = Install-OhMyPoshPortable -Confirm:$false 6>$null + Assert-True ($result -eq $target -and $script:DownloadCalls -eq 0) 'a managed build newer than the pin was replaced' + $script:StubVersion = [version]'30.0.0' + $result = Install-OhMyPoshPortable -Confirm:$false 3>$null 6>$null + Assert-True ($null -eq $result -and $script:DownloadCalls -eq 1) "a download whose hash differs was accepted: $result" + Assert-True ([System.IO.File]::ReadAllText($target) -eq 'installed') 'the installed build was replaced by a download whose hash differs' +} + +Test-Case 'Retired keybindings are reverted, recorded or tagged by an earlier version; user bindings stay' { + $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": [ { "command": "unfocusPane", "id": "User.mine" } ], "keybindings": [ { "id": "User.mine", "keys": "ctrl+a" } ] }' + $ctrlA = @([PSCustomObject]@{ keys = 'ctrl+a'; command = 'selectAll' }) + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Keybindings $ctrlA -Confirm:$false + Assert-True (@((Read-JsonFile $path).keybindings | Where-Object { $_.id -eq 'User.profile.ctrla' }).Count -eq 1) 'binding not applied' + # A run without -Keybindings (Set-TerminalBackground) keeps it. + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Defaults ([PSCustomObject]@{ opacity = 80 }) -Confirm:$false + Assert-True (@((Read-JsonFile $path).keybindings | Where-Object { $_.id -eq 'User.profile.ctrla' }).Count -eq 1) 'binding removed by a run that did not sync keybindings' + # The configuration no longer lists ctrl+a: the user's own binding comes back and the record is dropped. + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Keybindings @() -Confirm:$false + $wt = Read-JsonFile $path + Assert-True (@($wt.keybindings | Where-Object { $_.keys -eq 'ctrl+a' -and $_.id -eq 'User.mine' }).Count -eq 1) "the user's ctrl+a binding was not restored: $(ConvertTo-PspCanonicalJson $wt.keybindings)" + Assert-True (@($wt.actions | Where-Object { $_.id -eq 'User.profile.ctrla' }).Count -eq 0) 'the retired action was left behind' + $entry = Get-PspProp (Read-JsonFile $manifest).files $path.ToLowerInvariant() + Assert-True (-not (Test-PspProp $entry.bindings 'ctrl+a')) 'the retired binding is still recorded' + # A binding tagged by a version without a change record is removed too; the pre-install copy had none. + $legacy = New-WtSettings -Json '{ "profiles": { "defaults": {}, "list": [] }, "actions": [ { "command": "selectAll", "id": "User.profile.ctrla" }, { "command": "find", "id": "User.find" } ], "keybindings": [ { "id": "User.profile.ctrla", "keys": "ctrl+a" }, { "id": "User.find", "keys": "ctrl+f" } ] }' -Variant 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe' + [System.IO.File]::WriteAllText("$legacy.psp-original", '{ "profiles": { "defaults": {}, "list": [] }, "actions": [ { "command": "find", "id": "User.find" } ], "keybindings": [ { "id": "User.find", "keys": "ctrl+f" } ] }', $utf8) + $null = Update-WindowsTerminalSettingsFile -Path $legacy -ManifestPath $manifest -Keybindings @() -Confirm:$false + $wt = Read-JsonFile $legacy + Assert-True (@($wt.keybindings | Where-Object { $_.keys -eq 'ctrl+a' }).Count -eq 0 -and @($wt.actions | Where-Object { $_.id -eq 'User.profile.ctrla' }).Count -eq 0) 'the legacy ctrl+a binding was left behind' + Assert-True (@($wt.keybindings | Where-Object { $_.keys -eq 'ctrl+f' }).Count -eq 1) 'an unrelated user binding was removed' +} + +Test-Case '-NoLogo goes right after the executable and only into PowerShell command lines' { + $manifest = Join-Path (Get-PspCacheDir) 'wt-changes.json' + New-Item -ItemType Directory -Path (Get-PspCacheDir) -Force | Out-Null + $q = [char]34 + $list = @( + @{ guid = '{p1}'; name = 'Quoted'; commandline = "${q}C:\Program Files\PowerShell\7\pwsh.exe${q} -NoExit" } + @{ guid = '{p2}'; name = 'Via cmd'; commandline = 'cmd.exe /k pwsh' } + @{ guid = '{p3}'; name = 'Abbreviated'; commandline = 'pwsh.exe -nol' } + @{ guid = '{p4}'; name = 'PowerShell 7 Preview'; source = 'Windows.Terminal.PowerShellCore' } + @{ guid = '{p5}'; name = 'PowerShell'; source = 'Windows.Terminal.PowerShellCore' } + @{ guid = '{p6}'; name = 'Windows PowerShell'; commandline = '%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe' } + ) + $path = New-WtSettings -Json (ConvertTo-Json -InputObject ([PSCustomObject]@{ profiles = [PSCustomObject]@{ defaults = [PSCustomObject]@{}; list = $list } }) -Depth 5) + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -NoLogo -Confirm:$false + $byName = @{} + foreach ($p in @((Read-JsonFile $path).profiles.list)) { $byName[$p.name] = [string](Get-PspProp $p 'commandline') } + Assert-True ($byName['Quoted'] -eq "${q}C:\Program Files\PowerShell\7\pwsh.exe${q} -NoLogo -NoExit") "quoted path: $($byName['Quoted'])" + Assert-True ($byName['Via cmd'] -eq 'cmd.exe /k pwsh') "another program's command line was changed: $($byName['Via cmd'])" + Assert-True ($byName['Abbreviated'] -eq 'pwsh.exe -nol') "an abbreviated -NoLogo got a second one: $($byName['Abbreviated'])" + Assert-True (-not $byName['PowerShell 7 Preview']) "the Preview profile was pointed at pwsh.exe: $($byName['PowerShell 7 Preview'])" + Assert-True ($byName['PowerShell'] -eq 'pwsh.exe -NoLogo') "the generated PowerShell profile: $($byName['PowerShell'])" + Assert-True ($byName['Windows PowerShell'] -eq '%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo') "Windows PowerShell: $($byName['Windows PowerShell'])" +} + +Test-Case 'admin passes every argument to the elevated shell as a quoted literal' { + $global:PspStartedArgs = $null + Set-Item -Path 'function:global:Start-Process' -Value { param($FilePath, $Verb, $ArgumentList) $global:PspStartedArgs = @($ArgumentList) -join ' ' } + admin Write-Output 'a;calc' '-Name:' "it's" -Flag + $encoded = [regex]::Match([string]$global:PspStartedArgs, '-EncodedCommand\s+"?([A-Za-z0-9+/=]+)').Groups[1].Value + $command = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + Assert-True ($command -eq "& 'Write-Output' 'a;calc' -Name: 'it''s' -Flag") "unexpected elevated command: $command" + $global:PspStartedArgs = $null +} + +Test-Case 'hb -Force -WhatIf uploads nothing' { + $file = Join-Path $script:Sandbox.Work 'paste.txt' + [System.IO.File]::WriteAllText($file, 'secret-ish', $utf8) + $script:Uploads = 0 + function Invoke-RestMethod { $script:Uploads++ } + hb $file -Force -WhatIf 6>$null 3>$null + Assert-True ($script:Uploads -eq 0) 'hb uploaded under -WhatIf' +} + +Test-Case 'sed leaves a file without the text untouched and works on PowerShell drives' { + $file = Join-Path $script:Sandbox.Work 'conf.ini' + [System.IO.File]::WriteAllText($file, "a=1`r`n", $utf8) + $stamp = (Get-Item -LiteralPath $file).LastWriteTimeUtc + Start-Sleep -Milliseconds 30 + sed $file 'missing' 'x' 6>$null + Assert-True ((Get-Item -LiteralPath $file).LastWriteTimeUtc -eq $stamp -and -not (Test-Path -LiteralPath "$file.bak")) 'a file without the text was rewritten or backed up' + $null = New-PSDrive -Name PspSed -PSProvider FileSystem -Root $script:Sandbox.Work -Scope Global + try { + sed 'PspSed:\conf.ini' 'a=1' 'a=2' 6>$null + Assert-True ([System.IO.File]::ReadAllText($file) -eq "a=2`r`n" -and (Test-Path -LiteralPath "$file.bak")) 'sed on a PowerShell drive path failed' + } + finally { Remove-PSDrive -Name PspSed -Scope Global -ErrorAction SilentlyContinue } + $ErrorActionPreference = 'Continue' + $out = sed $script:Sandbox.Work 'a' 'b' 2>&1 | Out-String + Assert-True ($out -match 'File not found') 'a folder was accepted as a file' +} + +Test-Case 'envload skips variables that change how programs run unless -AllowSensitive' { + $envFile = Join-Path $script:Sandbox.Work '.env' + [System.IO.File]::WriteAllText($envFile, "PSP_ENV_OK=1`nGIT_SSH_COMMAND=calc.exe`nNODE_OPTIONS=--require ./x.js`nPATH=C:\evil`n", $utf8) + $savedPath = $env:PATH + try { + $warn = envload $envFile 3>&1 6>$null | Out-String + Assert-True ($env:PSP_ENV_OK -eq '1') 'a plain variable was not loaded' + Assert-True (-not $env:GIT_SSH_COMMAND -and -not $env:NODE_OPTIONS -and $env:PATH -eq $savedPath) 'a sensitive variable was loaded without -AllowSensitive' + Assert-True ($warn -match 'GIT_SSH_COMMAND' -and $warn -match 'AllowSensitive') "the skipped names are not reported: $warn" + envload $envFile -AllowSensitive 3>$null 6>$null + Assert-True ($env:GIT_SSH_COMMAND -eq 'calc.exe') '-AllowSensitive did not load the variable' + } + finally { + $env:PATH = $savedPath + foreach ($n in 'PSP_ENV_OK', 'GIT_SSH_COMMAND', 'NODE_OPTIONS') { Remove-Item -LiteralPath "env:$n" -ErrorAction SilentlyContinue } + } +} + +Test-Case 'prettyjson accepts pipeline input and keeps a top-level array an array' { + $out = '[1, 2]' | prettyjson | Out-String + Assert-True ($out -notmatch '"value"' -and $out -notmatch '"Count"') "array re-indented as an object: $out" + $multi = @('{', ' "a": [1,', '2]', '}') | prettyjson | Out-String + Assert-True ((ConvertFrom-Json $multi).a.Count -eq 2) "multi-line pipeline input failed: $multi" +} + +Test-Case 'bak copies a folder next to itself with its contents, also with a trailing separator' { + $dir = Join-Path $script:Sandbox.Work 'site' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $dir 'index.html'), 'hi', $utf8) + bak ($dir + '\') 6>$null + $copy = @(Get-ChildItem -LiteralPath $script:Sandbox.Work -Directory -Filter 'site.*.bak') + Assert-True ($copy.Count -eq 1 -and (Test-Path -LiteralPath (Join-Path $copy[0].FullName 'index.html'))) 'folder backup missing or empty' + Assert-True (@(Get-ChildItem -LiteralPath $dir -Force).Count -eq 1) 'the backup landed inside the folder' +} + +Test-Case 'killport, Stop-StuckProcess and Remove-LockedItem never stop this session or system processes' { + $script:Stopped = @() + function Get-NetTCPConnection { [PSCustomObject]@{ OwningProcess = $PID; LocalPort = 5000 } } + function Stop-Process { param($Id) $script:Stopped += $Id } + killport 5000 -Confirm:$false 3>$null 6>$null + Stop-StuckProcess -Id $PID -Confirm:$false 3>$null 6>$null + Assert-True ($script:Stopped.Count -eq 0) "this session was stopped: $($script:Stopped -join ',')" + $file = Join-Path $script:Sandbox.Work 'locked.bin' + [System.IO.File]::WriteAllText($file, 'x', $utf8) + function Remove-Item { throw 'locked' } + function Find-FileLocker { [PSCustomObject]@{ PID = 4; Name = 'System'; WindowTitle = '' } } + function Stop-StuckProcess { $script:Stopped += 'stuck' } + Remove-LockedItem $file -KillSystem -Confirm:$false -ErrorAction SilentlyContinue 3>$null 6>$null + Assert-True ($script:Stopped.Count -eq 0) 'Remove-LockedItem -KillSystem stopped a system process' +} + +Test-Case 'mtr counts a PowerShell 7 TimedOut reply as loss, and firewall and DNS failures are reported as errors' { + function Test-NetConnection { [PSCustomObject]@{ TraceRoute = @('10.0.0.1') } } + function Test-Connection { [PSCustomObject]@{ Status = 'TimedOut'; Latency = 0 } } + $out = mtr example.invalid -Count 2 6>&1 | Out-String + Assert-True ($out -match '10\.0\.0\.1\s+100%') "a timed-out hop was not counted as loss: $out" + $saved = $script:PSP.IsAdmin + $script:PSP.IsAdmin = $true + try { + function New-NetFirewallRule { throw 'access denied' } + $err = $null + fwallow -Name 'PSP test' -Port 1 -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable err 6>$null + Assert-True ((@($err) -join ' ') -match 'Could not add firewall rule') 'a failed firewall rule was not reported' + } + finally { $script:PSP.IsAdmin = $saved } + function Clear-DnsClientCache { throw 'access denied' } + $ErrorActionPreference = 'Continue' + $out = flushdns 2>&1 6>$null | Out-String + Assert-True ($out -match 'Could not flush') "a failed DNS flush was reported as success: $out" +} + +Test-Case 'ConvertTo-NativeArgumentLine keeps an empty argument' { + Assert-True ((ConvertTo-NativeArgumentLine @('a', '', 'b c')) -eq 'a "" "b c"') "unexpected: $(ConvertTo-NativeArgumentLine @('a', '', 'b c'))" +} + +Test-Case 'reload warns unless dot-sourced, and runs the profile either way' { + New-Item -ItemType Directory -Path (Split-Path $PROFILE) -Force | Out-Null + [System.IO.File]::WriteAllText($PROFILE, '$global:PspReloadRuns++', $utf8) + $global:PspReloadRuns = 0 + $warn = reload 3>&1 | Out-String + Assert-True ($global:PspReloadRuns -eq 1 -and $warn -match '\. reload') "reload did not warn or did not run the profile: $warn" + $warn = . reload 3>&1 | Out-String + Assert-True ($global:PspReloadRuns -eq 2 -and [string]::IsNullOrWhiteSpace($warn)) "a dot-sourced reload warned or did not run: $warn" + Remove-Variable -Name PspReloadRuns -Scope Global -ErrorAction SilentlyContinue +} + +Test-Case 'Update-Profile saves profile.original.ps1 before rotation, which keeps only timestamped copies' { + Install-DownloadStub + $global:PspStubSums = (@('Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json') | ForEach-Object { '{0} {1}' -f (Get-CommittedSha256 $_).ToLowerInvariant(), $_ }) -join "`n" + try { + $d = $script:Sandbox.Ps7Dir + New-Item -ItemType Directory -Path $d -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $d 'Microsoft.PowerShell_profile.ps1'), "### PowerShell Profile (26zl)`r`n# older installed copy", $utf8) + [System.IO.File]::WriteAllText((Join-Path $d 'oldprofile.20240101-000000.ps1'), '# my own profile', $utf8) + (Get-Item -LiteralPath (Join-Path $d 'oldprofile.20240101-000000.ps1')).LastWriteTime = (Get-Date).AddDays(-30) + foreach ($i in 1..6) { [System.IO.File]::WriteAllText((Join-Path $d ('oldprofile.2025010{0}-000000.ps1' -f $i)), "### PowerShell Profile (26zl)`r`n# copy $i", $utf8) } + [System.IO.File]::WriteAllText((Join-Path $d 'oldprofile.keep.ps1'), '# not a timestamped backup', $utf8) + Update-Profile -Ref v9.9.9 -ErrorAction SilentlyContinue *>&1 | Out-Null + 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' + $stamped = @(Get-ChildItem -LiteralPath $d -Filter 'oldprofile.*.ps1' | Where-Object { $_.Name -match '^oldprofile\.\d{8}-\d{6}\.ps1$' }) + Assert-True ($stamped.Count -eq 5) "expected 5 timestamped backups, found $($stamped.Count)" + Assert-True (Test-Path -LiteralPath (Join-Path $d 'oldprofile.keep.ps1')) 'a backup without a timestamp was rotated away' + } + finally { $global:PspStubSums = $null } +} + +Test-Case 'setup.ps1 stops with exit code 2 and installs nothing when the wizard summary is declined' { + # setup.ps1 runs in its own script scope, so the answers live in a global queue. + $global:PspWizardQueue = [System.Collections.Generic.Queue[string]]::new() + foreach ($a in 'y', 'n') { $global:PspWizardQueue.Enqueue($a) } + function Read-Host { param($Prompt, [switch]$AsSecureString) if ($global:PspWizardQueue.Count -eq 0) { throw "unexpected prompt: $Prompt" }; $global:PspWizardQueue.Dequeue() } + try { $out = & $setupPath -LocalRepo $repoRoot -CiMode -Wizard *>&1 | Out-String } + finally { Remove-Variable -Name PspWizardQueue -Scope Global -ErrorAction SilentlyContinue } + Assert-True ($LASTEXITCODE -eq 2) "exit code $LASTEXITCODE`: $out" + Assert-True ($out -match 'nothing was installed') 'the cancellation is not reported' + Assert-True (-not (Test-Path -LiteralPath $PROFILE)) 'the profile was installed after the summary was declined' +} + +Test-Case 'Runs of a script or command count as non-interactive unless -NoExit comes first' { + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $loop = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.ForEachStatementAst] -and $n.Extent.Text -match '_pspScriptRun = \$true' }, $true) | Select-Object -First 1 + Assert-True ($null -ne $loop) 'the command-line scan was not found' + $scan = [scriptblock]::Create('param($a) $_pspArgs = $a; $_pspScriptRun = $false; ' + $loop.Extent.Text + '; $_pspScriptRun') + $cases = @( + @{ Args = @('pwsh.exe'); Expected = $false } + @{ Args = @('pwsh.exe', '-NoLogo', '-WorkingDirectory', 'C:\work'); Expected = $false } + @{ Args = @('pwsh.exe', '-ex', 'Bypass'); Expected = $false } + @{ Args = @('pwsh.exe', '-File', 'x.ps1'); Expected = $true } + @{ Args = @('pwsh.exe', '-c', 'Get-Date'); Expected = $true } + @{ Args = @('pwsh.exe', '/Command', 'Get-Date'); Expected = $true } + @{ Args = @('powershell.exe', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', 'ZQBjAGgAbwA='); Expected = $true } + @{ Args = @('pwsh.exe', '-NoExit', '-Command', 'Get-Date'); Expected = $false } + @{ Args = @('pwsh.exe', '-noe', '-c', 'Get-Date'); Expected = $false } + @{ Args = @('pwsh.exe', '-Command', 'Get-Date', '-NoExit'); Expected = $true } + @{ Args = @('pwsh.exe', '-NoLogo'); Expected = $false } + @{ Args = @('pwsh.exe', '-e', 'ZQBjAGgAbwA='); Expected = $true } + ) + foreach ($case in $cases) { + $result = & $scan $case.Args + Assert-True ($result -eq $case.Expected) ("{0}: expected {1}, got {2}" -f ($case.Args -join ' '), $case.Expected, $result) + } +} + +Test-Case 'The startup garbage collection pause is skipped when not interactive and ends once, at the first prompt' { + # This test session is not interactive, so loading the profile reserved nothing. + Assert-True (-not $script:PspNoGcRegion -and [System.Runtime.GCSettings]::LatencyMode -ne [System.Runtime.GCLatencyMode]::NoGCRegion) 'a non-interactive load held garbage collection off' + $before = [System.Runtime.GCSettings]::LatencyMode + $script:PspNoGcRegion = [System.GC]::TryStartNoGCRegion(16MB) + try { + Assert-True ($script:PspNoGcRegion -and [System.Runtime.GCSettings]::LatencyMode -eq [System.Runtime.GCLatencyMode]::NoGCRegion) 'the test could not start a no-GC region' + $null = Get-PspPlainPrompt + Exit-PspNoGcRegion + Assert-True ([System.Runtime.GCSettings]::LatencyMode -eq $before -and -not $script:PspNoGcRegion) "the region did not end: $([System.Runtime.GCSettings]::LatencyMode)" + Exit-PspNoGcRegion + Assert-True ([System.Runtime.GCSettings]::LatencyMode -eq $before) 'a second call changed the latency mode' + } + finally { + if ([System.Runtime.GCSettings]::LatencyMode -eq [System.Runtime.GCLatencyMode]::NoGCRegion) { [System.GC]::EndNoGCRegion() } + $script:PspNoGcRegion = $false + } +} + +Test-Case 'The history filter is hooked into PSReadLine and keeps ordinary lines' { + Import-Module PSReadLine -ErrorAction Stop + $saved = (Get-PSReadLineOption).AddToHistoryHandler + try { + Register-ProfileHistoryHandler + $handler = (Get-PSReadLineOption).AddToHistoryHandler + Assert-True ($null -ne $handler) 'no history handler is registered' + Assert-True ($handler.Invoke('pwnd hunter2') -eq $false) 'a pwnd password reached history' + Assert-True ($handler.Invoke('$s = ConvertTo-SecureString ''x'' -AsPlainText -Force') -eq $false) 'an -AsPlainText line reached history' + # PSReadLine 2.2+ answers with its own filter's verdict; 2.0 (Windows PowerShell's built-in copy) has none. + $kept = [string]$handler.Invoke('Get-ChildItem') + Assert-True ($kept -in 'True', 'MemoryAndFile') "an ordinary line was not kept: $kept" + } + finally { if ($null -ne $saved) { Set-PSReadLineOption -AddToHistoryHandler $saved } } +} + +Test-Case 'Keybinding ids keep punctuation keys apart, and a binding dropped from the configuration is reported as pending' { + $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": [] }' + $bindings = @( + [PSCustomObject]@{ keys = 'ctrl+='; command = [PSCustomObject]@{ action = 'adjustFontSize'; delta = 1 } } + [PSCustomObject]@{ keys = 'ctrl+-'; command = [PSCustomObject]@{ action = 'adjustFontSize'; delta = -1 } } + [PSCustomObject]@{ keys = 'ctrl+a'; command = 'selectAll' } + ) + $null = Update-WindowsTerminalSettingsFile -Path $path -ManifestPath $manifest -Keybindings $bindings -Confirm:$false + $wt = Read-JsonFile $path + $ids = @($wt.keybindings | ForEach-Object { $_.id }) + Assert-True ($ids.Count -eq 3 -and @($ids | Select-Object -Unique).Count -eq 3) "ids collide: $($ids -join ', ')" + Assert-True ($ids -contains 'User.profile.ctrla') 'a binding of letters only changed its id' + $plusId = ($wt.keybindings | Where-Object { $_.keys -eq 'ctrl+=' }).id + Assert-True ((@($wt.actions | Where-Object { $_.id -eq $plusId })[0]).command.delta -eq 1) 'ctrl+= runs the command of another binding' + Assert-True (-not (Test-WtRetiredKeybindingPending -Keybindings $bindings -ManifestPath $manifest)) 'a configured binding counts as retired' + Assert-True (Test-WtRetiredKeybindingPending -Keybindings @($bindings[0], $bindings[1]) -ManifestPath $manifest) 'a binding dropped from the configuration is not reported' +} + +Test-Case 'Update-SessionPathFromRegistry keeps the session entries and adds each missing registry entry once' { + # The sandbox stubs this function out, so the real one is loaded here. + . ([scriptblock]::Create((Get-PspFunctionSource -Path $profilePath -Name 'Update-SessionPathFromRegistry'))) + $saved = $env:PATH + try { + $machine = @(([string][Environment]::GetEnvironmentVariable('PATH', 'Machine')) -split ';' | Where-Object { $_ }) + $env:PATH = 'C:\psp-session-only;' + $(if ($machine.Count) { $machine[0] } else { '' }) + Update-SessionPathFromRegistry + $entries = @($env:PATH -split ';' | Where-Object { $_ }) + Assert-True ($entries[0] -eq 'C:\psp-session-only') 'a session-only entry was dropped or moved' + foreach ($m in $machine) { + Assert-True (@($entries | Where-Object { $_.TrimEnd('\') -eq $m.TrimEnd('\') }).Count -eq 1) "registry entry missing or repeated: $m" + } + } + finally { $env:PATH = $saved } +} + +Test-Case 'lint always passes its own settings, so the analyzed folder cannot load custom rules' { + $script:LintArgs = $null + function Get-Module { param([switch]$ListAvailable, $Name) $null = $ListAvailable, $Name; [PSCustomObject]@{ Name = 'PSScriptAnalyzer' } } + function Import-Module { } + function Invoke-ScriptAnalyzer { param($Path, [switch]$Recurse, $Settings, [switch]$Fix) $null = $Path, $Recurse, $Fix; $script:LintArgs = @{ Settings = $Settings }; @() } + lint $script:Sandbox.Work 6>$null | Out-Null + Assert-True ($null -ne $script:LintArgs -and $script:LintArgs.Settings -is [hashtable]) 'lint ran without -Settings' + lint $script:Sandbox.Work -Mode CI 6>$null | Out-Null + Assert-True (@($script:LintArgs.Settings.ExcludeRules) -contains 'PSAvoidUsingWriteHost') 'the CI mode did not pass its rule list' +} + +Test-Case 'Quoted literals for the elevated shell also double typographic single quotes' { + $global:PspStartedArgs = $null + Set-Item -Path 'function:global:Start-Process' -Value { param($FilePath, $Verb, $ArgumentList) $global:PspStartedArgs = @($ArgumentList) -join ' ' } + $value = 'a' + [char]0x2019 + '; calc' + admin Write-Output $value + $encoded = [regex]::Match([string]$global:PspStartedArgs, '-EncodedCommand\s+"?([A-Za-z0-9+/=]+)').Groups[1].Value + $command = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + $global:PspStartedArgs = $null + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($command, [ref]$tokens, [ref]$parseErrors) + $statements = @($ast.EndBlock.Statements) + Assert-True ($statements.Count -eq 1 -and @($parseErrors).Count -eq 0) "the argument ended the quoted string early: $command" + $elements = @($statements[0].PipelineElements[0].CommandElements) + Assert-True ($elements.Count -eq 2 -and $elements[1].Value -eq $value) "the argument arrived changed: $command" +} + +Test-Case 'envload also skips build, cloud and tool settings that can run programs' { + $envFile = Join-Path $script:Sandbox.Work '.env' + $names = 'KUBECONFIG', 'JAVA_OPTS', 'RUSTC_WRAPPER', 'AWS_CONFIG_FILE', 'GOOGLE_APPLICATION_CREDENTIALS', 'CC', 'LESSOPEN', 'CARGO_HOME', 'DOCKER_HOST' + [System.IO.File]::WriteAllText($envFile, ((@($names | ForEach-Object { "$_=x" }) + 'PSP_ENV_PLAIN=1') -join "`n"), $utf8) + $saved = @{} + foreach ($n in $names) { $saved[$n] = [Environment]::GetEnvironmentVariable($n) } + try { + envload $envFile 3>$null 6>$null + Assert-True ($env:PSP_ENV_PLAIN -eq '1') 'a plain variable was not loaded' + foreach ($n in $names) { Assert-True ([Environment]::GetEnvironmentVariable($n) -eq $saved[$n]) "$n was loaded without -AllowSensitive" } + } + finally { + foreach ($n in $names) { [Environment]::SetEnvironmentVariable($n, $saved[$n]) } + Remove-Item -LiteralPath 'env:PSP_ENV_PLAIN' -ErrorAction SilentlyContinue + } +} + +Test-Case 'The history filter drops newer token shapes and keeps ordinary lines' { + # The token shapes are assembled here so the repository secret scan does not match the test itself. + $lines = @( + ('$k = ''' + 'sk-' + 'proj-' + ('a' * 24) + '''') + ('git push https://oauth2:' + 'glpat-' + ('b' * 20) + '@gitlab.example') + ('az storage blob list --connection-string ''' + 'AccountKey=' + ('c' * 12) + '''') + ('curl ''https://acct.blob.core.windows.net/c/f?sv=2024&' + 'sig=' + ('d' * 24) + '''') + ('$env:DB_' + 'PASS = ''x''') + ) + foreach ($line in $lines) { Assert-True (-not (Test-ProfileHistorySafeLine -Line $line)) "kept in history: $line" } + Assert-True (Test-ProfileHistorySafeLine -Line 'Get-ChildItem -Path C:\work') 'an ordinary line was dropped' +} + +Test-Case 'Uninstall-Profile restores the original once and then leaves a profile that is not this one alone' { + $d = $script:Sandbox.Ps7Dir + $main = Join-Path $d 'Microsoft.PowerShell_profile.ps1' + [System.IO.File]::WriteAllText($main, '### PowerShell Profile (26zl)' + "`r`n# installed copy", $utf8) + [System.IO.File]::WriteAllText((Join-Path $d 'profile.original.ps1'), '# my own profile', $utf8) + Uninstall-Profile -Confirm:$false 6>$null 3>$null | Out-Null + Assert-True ([System.IO.File]::ReadAllText($main) -eq '# my own profile') 'the original profile was not restored' + [System.IO.File]::WriteAllText($main, '# my own profile, edited', $utf8) + Uninstall-Profile -Confirm:$false 6>$null 3>$null | Out-Null + Assert-True ([System.IO.File]::ReadAllText($main) -eq '# my own profile, edited') 'a second uninstall replaced a profile that is not this one' +} + +Test-Case 'genpass copies through the clipboard that skips history and writes nothing to the success stream' { + $script:Copied = $null + function Set-PspSensitiveClipboard { param($Text) $script:Copied = $Text } + $out = genpass -Length 24 6>$null + Assert-True ($null -eq $out) 'genpass wrote the password to the success stream' + Assert-True (([string]$script:Copied).Length -eq 24) "copied: $([string]$script:Copied)" +} + +Test-Case 'Install-OhMyPoshPortable installs only a build signed by the Oh My Posh author' { + $target = Get-OhMyPoshPortablePath + function Invoke-DownloadWithRetry { param($Uri, $OutFile, $TimeoutSec) $null = $Uri, $TimeoutSec; [System.IO.File]::WriteAllText($OutFile, 'downloaded build', [System.Text.UTF8Encoding]::new($false)) } + # The hash check passes, so only the signature decides. + function Get-FileHash { param($LiteralPath, $Algorithm) $null = $Algorithm; [PSCustomObject]@{ Hash = [string](Get-PspComponentPins).OhMyPoshSha256[[System.IO.Path]::GetFileName($LiteralPath)] } } + $script:Signature = [PSCustomObject]@{ Status = 'Valid'; SignerCertificate = [PSCustomObject]@{ Subject = 'CN=Someone Else' } } + function Get-AuthenticodeSignature { param($LiteralPath) $null = $LiteralPath; $script:Signature } + Assert-True ($null -eq (Install-OhMyPoshPortable -Force -Confirm:$false 3>$null 6>$null) -and -not [System.IO.File]::Exists($target)) 'a build signed by someone else was installed' + $script:Signature = [PSCustomObject]@{ Status = 'NotSigned'; SignerCertificate = $null } + Assert-True ($null -eq (Install-OhMyPoshPortable -Force -Confirm:$false 3>$null 6>$null) -and -not [System.IO.File]::Exists($target)) 'an unsigned build was installed' + $script:Signature = [PSCustomObject]@{ Status = 'Valid'; SignerCertificate = [PSCustomObject]@{ Subject = 'CN=Jan Joris De Dobbeleer, O=Jan Joris De Dobbeleer' } } + Assert-True ((Install-OhMyPoshPortable -Force -Confirm:$false 6>$null) -eq $target -and [System.IO.File]::ReadAllText($target) -eq 'downloaded build') 'a correctly signed build was not installed' + Assert-True ([System.IO.File]::Exists((Join-Path (Split-Path $target) 'psp-managed.txt'))) 'the managed marker was not written' +} + +Test-Case 'Versions named in README.md and SECURITY.md match the pins' { + . ([scriptblock]::Create((Get-PspAssignmentSource -Path $setupPath -Variable '$script:NerdFontVersion'))) + $pins = Get-PspComponentPins + $docs = [System.IO.File]::ReadAllText((Join-Path $repoRoot 'README.md')) + [System.IO.File]::ReadAllText((Join-Path $repoRoot 'SECURITY.md')) + $expected = @(('v' + $pins.OhMyPosh), $script:OmpThemesRef, ('v' + $script:NerdFontVersion), $pins.OhMyPoshMinimum) + foreach ($version in $expected) { Assert-True ($docs.Contains($version)) "the docs do not name $version" } + # Every release-shaped Oh My Posh or Nerd Fonts version in the docs is one of the pinned ones. + $named = @([regex]::Matches($docs, '(?$null + Assert-True (-not $ok -and -not $script:Extracted) 'an archive whose hash differs was installed' +} + +Test-Case 'setup.ps1 checks local release files against the SHA256SUMS.txt next to them' { + $dir = Join-Path $script:Sandbox.Work 'release' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $bundle = 'Microsoft.PowerShell_profile.ps1', 'theme.json', 'terminal-config.json' + foreach ($f in @('setup.ps1') + $bundle) { Copy-Item -LiteralPath (Join-Path $repoRoot $f) -Destination $dir } + $sums = @(foreach ($f in $bundle) { '{0} {1}' -f (Get-FileHash -LiteralPath (Join-Path $dir $f) -Algorithm SHA256).Hash.ToLowerInvariant(), $f }) + [System.IO.File]::WriteAllText((Join-Path $dir 'SHA256SUMS.txt'), ($sums -join "`n"), $utf8) + $out = & (Join-Path $dir 'setup.ps1') -LocalRepo $dir -CiMode -SkipWizard -WhatIf *>&1 | Out-String -Width 4096 + Assert-True ($out -match 'matches the SHA256SUMS.txt in') "matching files were not reported as verified: $out" + # One wrong line stops setup before anything is installed. + $sums[0] = ('0' * 64) + ' Microsoft.PowerShell_profile.ps1' + [System.IO.File]::WriteAllText((Join-Path $dir 'SHA256SUMS.txt'), ($sums -join "`n"), $utf8) + $out = & (Join-Path $dir 'setup.ps1') -LocalRepo $dir -CiMode -SkipWizard *>&1 | Out-String -Width 4096 + Assert-True ($LASTEXITCODE -ne 0 -and $out -match 'does not match the SHA256SUMS.txt') "a file that differs from SHA256SUMS.txt was accepted: $out" + Assert-True (-not (Test-Path -LiteralPath $PROFILE)) 'the profile was installed from files that failed the check' +} + $script:Stopwatch.Stop() Write-Host '' $color = if ($script:Failed -gt 0) { 'Red' } else { 'Green' } From 57456640cc93463bd4ef9837e2056595cf96c6d4 Mon Sep 17 00:00:00 2001 From: 26zl <143036376+26zl@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:27:48 +0100 Subject: [PATCH 2/2] Start about 40 ms faster by keeping the Management module off startup Importing Microsoft.PowerShell.Management costs about 27 ms, and startup triggered it through Test-Path and Join-Path in the profile, zoxide's init (Get-Location in __zoxide_pwd) and the tab-title hook, which also ran Join-Path, Test-Path and Get-Item on every prompt. These now use .NET and session-state calls, and the zoxide cache rewrites __zoxide_pwd the same way (a "; psp 2" header suffix regenerates older caches), so the module loads at the first cd or ls instead. Also at startup: the startup message is written through the host instead of the first Write-PspHost call (about 10 ms), user-settings.json and theme.json are read directly instead of through Read-JsonFile, the trust state is built only when trust keys exist, and the _psp variables are removed with one wildcard Remove-Variable. Time to the first prompt (hidden interactive console, median of 7): 675-681 ms, down from 715-719 ms; per prompt unchanged at 18 ms. --- Microsoft.PowerShell_profile.ps1 | 71 +++++++++++++++++++------------- tests/unit.ps1 | 48 +++++++++++++++++++++ 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index 754ed12..672671e 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -55,7 +55,8 @@ if (-not [System.IO.Directory]::Exists($cacheDir)) { } # Detect administrator status safely across PowerShell editions and platforms. -$_pspIsWindowsOS = (-not (Test-Path Variable:\IsWindows)) -or $IsWindows +# Startup and the first prompt avoid cmdlets of Microsoft.PowerShell.Management: importing it costs about 25 ms, paid later at the first cd instead. +$_pspIsWindowsOS = $PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows if ($_pspIsWindowsOS) { try { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { $isAdmin = $false } @@ -596,7 +597,7 @@ function Get-ExternalCommandPath { Where-Object { $_ -and [System.IO.Path]::IsPathRooted([string]$_) } | Select-Object -Unique foreach ($pathCandidate in $pathCandidates) { - if (Test-Path -LiteralPath $pathCandidate -PathType Leaf) { + if ([System.IO.File]::Exists($pathCandidate)) { return $pathCandidate } } @@ -1225,7 +1226,7 @@ function Get-OhMyPoshExecutablePath { foreach ($candidatePath in $candidatePaths) { if (-not [System.IO.File]::Exists($candidatePath)) { continue } - $candidateDir = Split-Path -Path $candidatePath -Parent + $candidateDir = [System.IO.Path]::GetDirectoryName($candidatePath) $pathEntries = @($env:PATH -split ';' | Where-Object { $_ }) if ($pathEntries -notcontains $candidateDir) { $env:PATH = (@($candidateDir, $env:PATH) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' @@ -1565,7 +1566,7 @@ function Complete-OhMyPoshCommand { # 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 } + else { $null = & ([System.IO.Path]::Combine($env:SystemRoot, 'System32\taskkill.exe')) /T /F /PID $process.Id 2>&1 } } catch { $null = $_ } return $null @@ -1637,7 +1638,7 @@ function Get-OhMyPoshPromptContext { } try { - $locations = Get-Location -Stack + $locations = $ExecutionContext.SessionState.Path.LocationStack($null) if ($locations) { $context.StackCount = $locations.Count } @@ -2947,7 +2948,8 @@ function Exit-PspNoGcRegion { # 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) + "] $ " } + $location = $ExecutionContext.SessionState.Path.CurrentLocation + if ($script:PSP.IsAdmin) { "[" + $location + "] # " } else { "[" + $location + "] $ " } } function prompt { @@ -8021,10 +8023,12 @@ $_pspReadlineColors = $null $_pspThemeConfig = $null $_pspRlUser = $null $_pspRlUserError = $null -try { $_pspRlUser = Read-JsonFile (Join-Path $cacheDir 'user-settings.json') } +$_pspUserSettingsPath = [System.IO.Path]::Combine($cacheDir, 'user-settings.json') +try { if ([System.IO.File]::Exists($_pspUserSettingsPath)) { $_pspRlUser = ConvertFrom-JsonC -Text ([System.IO.File]::ReadAllText($_pspUserSettingsPath)) } } catch { $_pspRlUserError = $_ } try { - $_pspThemeConfig = Read-JsonFile (Join-Path $cacheDir 'theme.json') + $_pspThemeJsonPath = [System.IO.Path]::Combine($cacheDir, 'theme.json') + if ([System.IO.File]::Exists($_pspThemeJsonPath)) { $_pspThemeConfig = ConvertFrom-JsonC -Text ([System.IO.File]::ReadAllText($_pspThemeJsonPath)) } if ($_pspThemeConfig) { if ($_pspThemeConfig.psreadline -and $_pspThemeConfig.psreadline.colors) { $_pspReadlineColors = @{} @@ -8165,14 +8169,17 @@ if ($script:PSP.IsInteractive -and (Get-Module PSReadLine)) { Register-ProfileHook -EventName PrePrompt -Action { try { $parts = @() - if ($env:VIRTUAL_ENV) { $parts += "venv:$(Split-Path $env:VIRTUAL_ENV -Leaf)" } + # .NET calls instead of file cmdlets: this runs on every prompt, and the first file cmdlet would import a module. + if ($env:VIRTUAL_ENV) { $parts += "venv:$([System.IO.Path]::GetFileName($env:VIRTUAL_ENV.TrimEnd('\', '/')))" } 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 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 + $kubeConfig = if ($env:KUBECONFIG) { @($env:KUBECONFIG -split ';' | Where-Object { $_ })[0] } else { [System.IO.Path]::Combine($HOME, '.kube', 'config') } + # A relative entry resolves against the current PowerShell location. + if ($kubeConfig) { $kubeConfig = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($kubeConfig) } + if ($kubeConfig -and [System.IO.File]::Exists($kubeConfig)) { + $kubeItem = [System.IO.FileInfo]::new($kubeConfig) $stamp = '{0}|{1}|{2}' -f $kubeItem.FullName, $kubeItem.LastWriteTimeUtc.Ticks, $kubeItem.Length $kubeCache = $script:PSP.KubeContextCache if (-not $kubeCache -or $kubeCache.Stamp -ne $stamp) { @@ -8248,7 +8255,7 @@ if ($script:PSP.IsInteractive) { Write-Warning "No cached OMP theme is configured. Run Update-Profile or setup.ps1 to restore theme.json." } - $_pspLocalThemePath = if ($_pspThemeName) { Join-Path $cacheDir "$_pspThemeName.omp.json" } else { $null } + $_pspLocalThemePath = if ($_pspThemeName) { [System.IO.Path]::Combine($cacheDir, "$_pspThemeName.omp.json") } else { $null } if ($_pspLocalThemePath -and -not [System.IO.File]::Exists($_pspLocalThemePath)) { # Move a theme file left in the profile folder into the cache. $_pspOldThemePath = Join-Path (Split-Path $PROFILE) "$_pspThemeName.omp.json" @@ -8452,7 +8459,10 @@ function New-PspZoxideCache { } -ArgumentList @($ExecutablePath) -TimeoutSec 10 if (-not $initScript) { return $false } $version = if ($versionText) { $versionText } else { 'unknown' } - [System.IO.File]::WriteAllText($CachePath, ("# ZOXIDE_CACHE_VERSION: $version`n" + (@($initScript) -join "`n")), [System.Text.UTF8Encoding]::new($false)) + # zoxide reads the folder at load with Get-Location, which would import Microsoft.PowerShell.Management; the session API returns the same PathInfo. + $text = (@($initScript) -join "`n").Replace('$cwd = Microsoft.PowerShell.Management\Get-Location', '$cwd = $ExecutionContext.SessionState.Path.CurrentLocation') + # The psp suffix marks caches written with that change, so older ones are generated again. + [System.IO.File]::WriteAllText($CachePath, ("# ZOXIDE_CACHE_VERSION: $version; psp 2`n" + $text), [System.Text.UTF8Encoding]::new($false)) return $true } @@ -8468,7 +8478,7 @@ if ($script:PSP.IsInteractive) { $_pspZoxideReader = [System.IO.StreamReader]::new($_pspZoxideCachePath) try { $_pspCacheContent = $_pspZoxideReader.ReadLine() } finally { $_pspZoxideReader.Dispose() } - if ($_pspCacheContent -match '^# ZOXIDE_CACHE_VERSION: .+') { $_pspCacheValid = $true } + if ($_pspCacheContent -match '^# ZOXIDE_CACHE_VERSION: .+; psp 2$') { $_pspCacheValid = $true } } catch { Write-Verbose "Zoxide cache read failed (will regenerate)." } if (-not $_pspCacheValid) { Remove-Item $_pspZoxideCachePath -Force -ErrorAction SilentlyContinue } @@ -9258,9 +9268,11 @@ if ($script:UserSettings) { # Apply feature toggles again in case the PSReadLine block skipped them. Set-PspFeatureOverride $script:UserSettings # 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['trustedDirs'] -or $script:UserSettings.PSObject.Properties['trustedRcHashes']) { + $_pspTrust = Get-PspTrustState $script:UserSettings + $script:PSP.TrustedDirs = $_pspTrust.Dirs + $script:PSP.TrustedRcHashes = $_pspTrust.Hashes + } if ($script:UserSettings.PSObject.Properties['commandOverrides']) { $_pspOverrideCount = @($script:UserSettings.commandOverrides.PSObject.Properties).Count if (-not $script:PSP.Features.commandOverrides) { @@ -9325,9 +9337,9 @@ if ([System.IO.Directory]::Exists($_pspPluginDir)) { # Check GitHub weekly for updates when the interactive updateCheck feature is enabled. if ($script:PSP.IsInteractive -and $script:PSP.Features.updateCheck) { try { - $_pspUcStampFile = Join-Path $cacheDir 'last-update-check.txt' + $_pspUcStampFile = [System.IO.Path]::Combine($cacheDir, 'last-update-check.txt') $_pspUcDoCheck = $true - if (Test-Path -LiteralPath $_pspUcStampFile) { + if ([System.IO.File]::Exists($_pspUcStampFile)) { $_pspUcRaw = (Get-Utf8FileText $_pspUcStampFile).Trim() $_pspUcLast = [datetime]::MinValue if ([datetime]::TryParse($_pspUcRaw, [ref]$_pspUcLast)) { @@ -9337,8 +9349,8 @@ if ($script:PSP.IsInteractive -and $script:PSP.Features.updateCheck) { 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 } + $_pspUcBaselineFile = [System.IO.Path]::Combine($cacheDir, 'applied-commit.sha') + $_pspUcStored = if ([System.IO.File]::Exists($_pspUcBaselineFile)) { (Get-Utf8FileText $_pspUcBaselineFile).Trim() } else { $null } if (-not $_pspUcStored) { # First check: record baseline so future checks can compare without false negatives. Write-Utf8FileAtomic -Path $_pspUcBaselineFile -Content $_pspUcLatest @@ -9365,11 +9377,14 @@ if ($script:PspNoGcRegion -and -not ([string]$Function:prompt).Contains('Exit-Ps $_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 + # Written through the host directly: the first Write-PspHost call costs about 10 ms more, on every start. + foreach ($_pspLine in @(@("Profile loaded in $($_pspStopwatch.ElapsedMilliseconds)ms.", [System.ConsoleColor]::DarkGray), @("Use 'Show-Help' or 'Start-ProfileTour' to explore.", [System.ConsoleColor]::Yellow))) { + try { + if ([string]::IsNullOrEmpty($env:NO_COLOR)) { $Host.UI.WriteLine($_pspLine[1], $Host.UI.RawUI.BackgroundColor, $_pspLine[0]) } + else { $Host.UI.WriteLine($_pspLine[0]) } + } + catch { Write-PspHost $_pspLine[0] -ForegroundColor $_pspLine[1] } + } } # 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 +Remove-Variable -Name '_psp*' -Scope Script -ErrorAction Ignore diff --git a/tests/unit.ps1 b/tests/unit.ps1 index 235e30a..7d871e3 100644 --- a/tests/unit.ps1 +++ b/tests/unit.ps1 @@ -1656,6 +1656,54 @@ Test-Case 'Versions named in README.md and SECURITY.md match the pins' { Assert-True ($stale.Count -eq 0) "versions in the docs that match no pin: $($stale -join ', ')" } +Test-Case 'The prompt path and the tab-title hook call no Microsoft.PowerShell.Management cmdlet (its import costs about 25 ms)' { + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $management = @{} + foreach ($c in (Get-Command -Module Microsoft.PowerShell.Management -CommandType Cmdlet)) { $management[$c.Name] = $true } + foreach ($a in (Get-Alias | Where-Object { $management.ContainsKey([string]$_.Definition) })) { $management[$a.Name] = $true } + $names = @('Invoke-PromptStage', 'Invoke-ProfileHook', 'Get-OhMyPoshPromptText', 'Get-OhMyPoshPromptContext', 'Invoke-OhMyPoshCommand', 'Complete-OhMyPoshCommand', + 'Get-OhMyPoshChildPath', 'Get-ProfileEnvironmentFingerprint', 'Get-PspPlainPrompt', 'Exit-PspNoGcRegion', 'Get-OhMyPoshExecutablePath', 'Get-ExternalCommandPath', + 'Find-ProfileExecutable', 'ConvertFrom-JsonC', 'Set-PspFeatureOverride', 'Get-PspCacheDir', 'Test-PspOmpVersionSafe', 'Get-PspExecutableVersion') + $bodies = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $names -contains $n.Name }, $true)) + Assert-True ($bodies.Count -eq $names.Count) "found $($bodies.Count) of $($names.Count) functions" + $bodies += @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Register-ProfileHook' -and $n.Extent.Text -match '-EventName PrePrompt' }, $true)) + $hits = @(foreach ($b in $bodies) { + foreach ($c in $b.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true)) { + $name = $c.GetCommandName() + if ($name -and $management.ContainsKey($name)) { '{0} (line {1})' -f $name, $c.Extent.StartLineNumber } + } + }) + Assert-True ($hits.Count -eq 0) "Management cmdlets on the prompt path: $($hits -join ', ')" +} + +Test-Case 'Loading the profile in a fresh process does not import Microsoft.PowerShell.Management' { + # The cache and plugin folders exist, as after any earlier start, so the load creates nothing. + New-Item -ItemType Directory -Path (Join-Path (Get-PspCacheDir) 'plugins') -Force | Out-Null + $shell = (Get-Process -Id $PID).Path + $out = & $shell -NoProfile -NonInteractive -Command ". '$profilePath' *> `$null; 'mgmt=' + [bool](Get-Module Microsoft.PowerShell.Management)" 2>&1 | Out-String + Assert-True ($out -match 'mgmt=False') "the load imported the module: $out" +} + +Test-Case 'The zoxide cache reads the folder through the session API, and the load accepts only caches written that way' { + function Invoke-WithTimeout { + param([scriptblock]$ScriptBlock, $ArgumentList, $TimeoutSec) + $null = $ArgumentList, $TimeoutSec + if ($ScriptBlock.ToString() -match 'init --cmd') { "function global:__zoxide_pwd {`n `$cwd = Microsoft.PowerShell.Management\Get-Location`n}" } else { 'zoxide 0.10.0' } + } + $cache = Join-Path $script:Sandbox.Work 'zoxide-init.ps1' + Assert-True (New-PspZoxideCache -ExecutablePath 'zoxide.exe' -CachePath $cache) 'the cache was not written' + $lines = [System.IO.File]::ReadAllLines($cache) + $text = $lines -join "`n" + Assert-True ($text.Contains('$cwd = $ExecutionContext.SessionState.Path.CurrentLocation') -and -not $text.Contains('Management\Get-Location')) 'Get-Location was not replaced' + # The header the writer produces is the one the load path checks for; an older header is regenerated. + $tokens = $null; $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($profilePath, [ref]$tokens, [ref]$parseErrors) + $check = ($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $n.Value.StartsWith('^# ZOXIDE_CACHE_VERSION') }, $true) | Select-Object -First 1).Value + Assert-True ($lines[0] -match $check) "the load would reject the header '$($lines[0])'" + Assert-True (-not ('# ZOXIDE_CACHE_VERSION: zoxide 0.10.0' -match $check)) 'a cache without the session API change is still accepted' +} + Test-Case 'Install-NerdFonts never extracts an archive whose hash differs' { $script:Extracted = $false function Invoke-DownloadWithRetry { param($Uri, $OutFile, $TimeoutSec, $MaxAttempts) $null = $Uri, $TimeoutSec, $MaxAttempts; [System.IO.File]::WriteAllText($OutFile, 'not the pinned archive', [System.Text.UTF8Encoding]::new($false)) }