diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d3b7b..0eeb253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Added + +- Setup offers PowerShell 7 (`pwsh`) as an alternative default shell, installed + from Microsoft's GitHub release `.deb` after its release-asset SHA-256 is + verified. A managed `~/.config/powershell/profile.ps1` mirrors the bashrc + (starship, zoxide, mise, aliases) and a `~/.squarebox-use-pwsh` marker hands + interactive logins off to pwsh, matching the existing Zsh/Fish flow. + ## v1.2.1 — 2026-07-31 v1.2.0 was built as a draft Candidate but was never published after final diff --git a/CLAUDE.md b/CLAUDE.md index 46d0a79..81b5611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ Key rules: - Preserve old Selection on prompt cancellation; intentional empty selection is distinct. - Commit Selection/aliases from successful observed installs, not requested values. -- Keep Bash, Zsh, and Fish derived configuration synchronized after section reruns. +- Keep Bash, Zsh, Fish, and PowerShell derived configuration synchronized after section reruns. - Respect explicit user configuration during migrations (for example tmux mouse off). - Runtime APT must work with the read-only timezone mount and show actionable failures. - `sqrbx-learn` and its command logger are not shipped in the default v1.1 Box. @@ -137,9 +137,10 @@ Managed-home volume copies. Bind mounts remain host-managed and are skipped; a symlink destination is rejected with a visible startup failure. Never follow a persistent-home symlink as root. -Bash is default. Experimental Zsh/Fish are selected with markers in the Managed -home and must initialize Starship, Zoxide, the `fzf`/`ff` command path, aliases, -and mise. The packaged Ctrl+R/Ctrl+T/Alt+C/** bindings are a Bash-only contract. +Bash is default. Experimental Zsh/Fish/PowerShell are selected with markers in +the Managed home and must initialize Starship, Zoxide, the `fzf`/`ff` command +path, aliases, and mise. The packaged Ctrl+R/Ctrl+T/Alt+C/** bindings are a +Bash-only contract. ## Security posture diff --git a/README.md b/README.md index ee3f112..c6f381b 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ Installed during first-run setup. Choose any combination, or none: ### Shell (Experimental) By default, squarebox uses Bash. During first-run setup you can opt in to -**Zsh** or **Fish** instead. +**Zsh**, **Fish**, or **PowerShell** instead. **Zsh** installs: @@ -324,14 +324,24 @@ autosuggestions and syntax highlighting built in. The generated AI/editor/TUI/SDK selections are translated from their bash files into `~/.config/fish/conf.d/squarebox-selections.fish` at setup time. -> **Experimental:** the marker file `~/.squarebox-use-zsh` (or -> `~/.squarebox-use-fish`) causes `~/.bashrc` to `exec` the chosen shell on -> every interactive login, so the next shell start picks up the new shell. -> Set `SQUAREBOX_NO_ZSH=1` or `SQUAREBOX_NO_FISH=1` to force bash for a single -> session, or re-run `sqrbx-setup shell` to switch back permanently. Tooling -> is primarily tested against bash, so a few edge cases may need polish — -> please file an issue if you hit one. SDK shims are wired into all three -> shells via `mise activate {bash,zsh,fish}`. +**PowerShell** installs PowerShell 7 (`pwsh`) from Microsoft's official +[GitHub release](https://github.com/PowerShell/PowerShell/releases) `.deb` +(asset SHA-256 verified against the release metadata before install). The +generated `~/.config/powershell/profile.ps1` mirrors the default bashrc in +PowerShell-native syntax — starship, zoxide, and mise activate, plus the +standard aliases as functions (PowerShell `Set-Alias` cannot attach +arguments); AI/editor/TUI selections are translated from their bash files into +`~/.config/powershell/squarebox-selections.ps1` at setup time. + +> **Experimental:** the marker file `~/.squarebox-use-zsh`, +> `~/.squarebox-use-fish`, or `~/.squarebox-use-pwsh` causes `~/.bashrc` to +> `exec` the chosen shell on every interactive login, so the next shell start +> picks up the new shell. Set `SQUAREBOX_NO_ZSH=1`, `SQUAREBOX_NO_FISH=1`, or +> `SQUAREBOX_NO_PWSH=1` to force bash for a single session, or re-run +> `sqrbx-setup shell` to switch back permanently. Tooling is primarily tested +> against bash, so a few edge cases may need polish — please file an issue if +> you hit one. SDK shims are wired into all four shells via +> `mise activate {bash,zsh,fish,powershell}`. ### SDKs diff --git a/demo/setup-demo.sh b/demo/setup-demo.sh index c479515..c4483c1 100755 --- a/demo/setup-demo.sh +++ b/demo/setup-demo.sh @@ -143,7 +143,7 @@ done <<< "$selected" echo section_header "Shell" selected=$(gum choose --header "Select the default shell:" \ - "bash" "zsh (experimental)" "fish (experimental)") || true + "bash" "zsh (experimental)" "fish (experimental)" "powershell (experimental)") || true [ -n "$selected" ] && run_with_spinner "Configuring ${selected}..." 0.7 echo diff --git a/dotfiles/bashrc b/dotfiles/bashrc index a9102d6..387afc1 100644 --- a/dotfiles/bashrc +++ b/dotfiles/bashrc @@ -79,4 +79,12 @@ if [ -f ~/.squarebox-use-fish ] && [ -z "${SQUAREBOX_IN_FISH:-}" ] && [ -z "${SQ exec fish -l fi +# Hand off to PowerShell if the user opted in via setup.sh (experimental). +# SQUAREBOX_IN_PWSH guards against re-exec loops; SQUAREBOX_NO_PWSH lets +# users force bash for one shell without removing the marker. +if [ -f ~/.squarebox-use-pwsh ] && [ -z "${SQUAREBOX_IN_PWSH:-}" ] && [ -z "${SQUAREBOX_NO_PWSH:-}" ] && command -v pwsh >/dev/null 2>&1; then + export SQUAREBOX_IN_PWSH=1 + exec pwsh -l +fi + [ -x /usr/local/lib/squarebox/motd.sh ] && /usr/local/lib/squarebox/motd.sh diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh index 5ddef27..492e200 100755 --- a/scripts/e2e-test.sh +++ b/scripts/e2e-test.sh @@ -306,6 +306,11 @@ suite_setup_editors() { else tap_fail "3.12c no fish marker for bash selection" fi + if [ ! -e ~/.squarebox-use-pwsh ]; then + tap_ok "3.12d no pwsh marker for bash selection" + else + tap_fail "3.12d no pwsh marker for bash selection" + fi # 4.4 EDITOR set to first selected editor (micro) run_test_grep "4.4 EDITOR set to micro" "micro" cat ~/.squarebox-editor-aliases diff --git a/scripts/squarebox-entrypoint.sh b/scripts/squarebox-entrypoint.sh index e7960ad..4808839 100755 --- a/scripts/squarebox-entrypoint.sh +++ b/scripts/squarebox-entrypoint.sh @@ -132,6 +132,10 @@ box_reconcile_needed() { command -v fish >/dev/null 2>&1 || return 0 [ -f "$managed_home/.config/fish/config.fish" ] || return 0 fi + if selection_contains "$state/shell" pwsh; then + command -v pwsh >/dev/null 2>&1 || return 0 + [ -f "$managed_home/.config/powershell/profile.ps1" ] || return 0 + fi return 1 } diff --git a/scripts/squarebox-setup.sh b/scripts/squarebox-setup.sh index b32195f..664f8dc 100644 --- a/scripts/squarebox-setup.sh +++ b/scripts/squarebox-setup.sh @@ -36,7 +36,7 @@ usage() { tuis TUI tools (lazygit, gh-dash, yazi, elio) multiplexers Terminal multiplexers (tmux, zellij, herdr) sdks SDKs (node, python, go, dotnet, rust) - shell Default shell (bash, zsh/fish — experimental) + shell Default shell (bash, zsh/fish/pwsh — experimental) ${BOLD}Examples:${RESET} sqrbx-setup ai editors Re-run AI assistant and editor selection diff --git a/setup.sh b/setup.sh index 2770b2b..a5d4b05 100755 --- a/setup.sh +++ b/setup.sh @@ -452,6 +452,63 @@ _refresh_fish_selections() { } > "$sel_out" } +# Translate managed bash aliases into PowerShell function syntax and keep the +# PowerShell selection snippet current. `Set-Alias` cannot attach arguments, so +# each alias becomes a function passing @args through — this handles bare +# commands (c/lg) and flag-carrying wrappers (claude-yolo) uniformly. +_squarebox_bash_line_to_pwsh() { + local line="$1" rest name val + case "$line" in + "export PATH="*) + local path_val="${line#export PATH=}" + path_val="${path_val#\"}"; path_val="${path_val%\"}" + path_val="${path_val#\'}"; path_val="${path_val%\'}" + path_val="${path_val//\$PATH/\$env:PATH}" + echo "\$env:PATH = \"$path_val\"" + ;; + "export "*=*) + rest="${line#export }" + name="${rest%%=*}" + val="${rest#*=}" + val="${val#\"}"; val="${val%\"}" + val="${val#\'}"; val="${val%\'}" + echo "\$env:$name = \"$val\"" + ;; + "alias "*=*) + rest="${line#alias }" + name="${rest%%=*}" + val="${rest#*=}" + val="${val#\"}"; val="${val%\"}" + val="${val#\'}"; val="${val%\'}" + [[ "$name" =~ ^[A-Za-z0-9_.-]+$ ]] || return 0 + # Reject values that would need PowerShell quoting (quotes, $, parens): + # keep only single-word tokens plus spaces and flag-safe punctuation. + [[ -z "${val//[A-Za-z0-9_.:=\/@+ -]/}" ]] || return 0 + echo "function $name { $val @args }" + ;; + esac +} + +_refresh_pwsh_selections() { + local conf_dir="$HOME/.config/powershell" + [ -d "$conf_dir" ] || return 0 + local sel_out="$conf_dir/squarebox-selections.ps1" + { + echo "# Generated by setup.sh from ~/.squarebox-* bash files." + local src _sq_line + for src in \ + "$HOME/.squarebox-ai-aliases" \ + "$HOME/.squarebox-editor-aliases" \ + "$HOME/.squarebox-tui-aliases"; do + [ -f "$src" ] || continue + echo "# --- from $(basename "$src") ---" + while IFS= read -r _sq_line; do + _squarebox_bash_line_to_pwsh "$_sq_line" + done < "$src" + done + } > "$sel_out" +} + # Optional tools install the latest upstream release at setup time. # Pinned versions live only in the Dockerfile tier (checksums.txt). # @@ -2009,25 +2066,29 @@ if $INTERACTIVE; then case "$shell_prev" in zsh) gum_selected="zsh (experimental)" ;; fish) gum_selected="fish (experimental)" ;; + pwsh) gum_selected="powershell (experimental)" ;; bash) gum_selected="bash" ;; esac gum_args=(--header "Select default shell:") [ -n "$gum_selected" ] && gum_args+=(--selected "$gum_selected") - if ! shell_pick=$(gum choose "${gum_args[@]}" "bash" "zsh (experimental)" "fish (experimental)"); then + if ! shell_pick=$(gum choose "${gum_args[@]}" \ + "bash" "zsh (experimental)" "fish (experimental)" "powershell (experimental)"); then cancel_setup fi case "$shell_pick" in - "zsh (experimental)") shell_choice="zsh" ;; - "fish (experimental)") shell_choice="fish" ;; - "bash") shell_choice="bash" ;; - *) shell_choice="$shell_prev" ;; + "zsh (experimental)") shell_choice="zsh" ;; + "fish (experimental)") shell_choice="fish" ;; + "powershell (experimental)") shell_choice="pwsh" ;; + "bash") shell_choice="bash" ;; + *) shell_choice="$shell_prev" ;; esac else echo "Select default shell:" for sh_item in \ "1:bash:GNU Bash (default)" \ "2:zsh:Zsh + Oh My Zsh + autosuggestions + syntax highlighting (experimental)" \ - "3:fish:Fish shell with built-in autosuggestions and syntax highlighting (experimental)"; do + "3:fish:Fish shell with built-in autosuggestions and syntax highlighting (experimental)" \ + "4:pwsh:PowerShell 7 (Microsoft .deb, verified from GitHub releases) (experimental)"; do num="${sh_item%%:*}"; rest="${sh_item#*:}"; key="${rest%%:*}"; desc="${rest#*:}" if [ "$key" = "$shell_prev" ]; then echo " ${num}) ${key} — ${desc} [current]" @@ -2035,7 +2096,7 @@ if $INTERACTIVE; then echo " ${num}) ${key} — ${desc}" fi done - read -rp "Selection [1,2,3/skip]: " shell_selection + read -rp "Selection [1,2,3,4/skip]: " shell_selection if [ -z "$shell_selection" ] && [ -n "$shell_prev" ]; then shell_choice="$shell_prev" else @@ -2043,6 +2104,7 @@ if $INTERACTIVE; then 1) shell_choice="bash" ;; 2) shell_choice="zsh" ;; 3) shell_choice="fish" ;; + 4) shell_choice="pwsh" ;; *) shell_choice="${shell_prev:-bash}" ;; esac fi @@ -2053,6 +2115,7 @@ elif [ -n "$shell_prev" ]; then case "$shell_choice" in zsh) echo "Configuring shell: zsh (from previous selection)" ;; fish) echo "Configuring shell: fish (from previous selection)" ;; + pwsh) echo "Configuring shell: pwsh (from previous selection)" ;; esac else shell_choice="bash" @@ -2277,11 +2340,116 @@ install_fish() { run_with_spinner "Installing Fish..." _install_fish_inner } +_install_pwsh_inner() { + local repo="PowerShell/PowerShell" body tag version pattern count digest url deb + # Resolve the exact latest release and one architecture-matching asset + # (with GitHub's release-asset SHA-256) before downloading or installing. + # GitHub metadata failure is authoritative: nothing is fetched or changed. + body=$(_sb_gh_api_get "${SB_GITHUB_API_BASE:-https://api.github.com}/repos/${repo}/releases/latest" "$repo") || return 1 + tag=$(printf '%s' "$body" | jq -er '.tag_name | select(type == "string" and length > 0)' 2>/dev/null) || { + echo "Error: no valid PowerShell release tag" >&2; return 1 + } + [[ "$tag" == v* ]] || { echo "Error: unexpected PowerShell release tag: $tag" >&2; return 1; } + version=${tag#v} + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.+-][0-9A-Za-z.-]+)?$ ]] || { + echo "Error: unsafe PowerShell release tag: $tag" >&2; return 1 + } + arch="${SB_DPKG_ARCH:-}" + [ -n "$arch" ] || arch=$(dpkg --print-architecture 2>/dev/null || uname -m 2>/dev/null || true) + case "$arch" in + amd64|x86_64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "Error: no PowerShell .deb for architecture: $arch" >&2; return 1 ;; + esac + pattern="powershell_${version}-1.deb_${arch}.deb" + count=$(printf '%s' "$body" | jq -er --arg pat "$pattern" \ + '[.assets[]? | select(.name == $pat)] | length' 2>/dev/null) || { + echo "Error: could not inspect PowerShell release assets" >&2; return 1 + } + [ "$count" -eq 1 ] || { + echo "Error: expected exactly one PowerShell asset for $arch at $tag; found ${count:-0}" >&2; return 1 + } + digest=$(printf '%s' "$body" | jq -er --arg pat "$pattern" \ + '.assets[]? | select(.name == $pat) | .digest' 2>/dev/null) || { + echo "Error: PowerShell release asset has no digest" >&2; return 1 + } + digest=${digest#sha256:} + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || { + echo "Error: PowerShell release asset digest is malformed" >&2; return 1 + } + url=$(printf '%s' "$body" | jq -er --arg pat "$pattern" \ + '.assets[]? | select(.name == $pat) | .browser_download_url' 2>/dev/null) || { + echo "Error: PowerShell release asset has no download URL" >&2; return 1 + } + [[ "$url" == https://* ]] || { echo "Error: refusing non-HTTPS PowerShell asset URL" >&2; return 1; } + + deb="$SB_TMPDIR/pwsh.deb" + if ! curl -fsSL --retry 3 -o "$deb" "$url"; then + echo "Error: failed to download PowerShell from $url" >&2 + return 1 + fi + if ! printf '%s %s\n' "$digest" "$deb" | sha256sum -c - >/dev/null 2>&1; then + echo "Error: PowerShell release asset SHA-256 mismatch" >&2 + return 1 + fi + # The .deb is resolved and verified before any Box mutation. Install it + # through apt so declared dependencies resolve; the Dockerfile sudoers + # grant dev passwordless apt-get/dpkg. + apt_install "$deb" || return 1 + command -v pwsh >/dev/null 2>&1 || { + echo "Error: pwsh not observable after install" >&2; return 1 + } + # Generate ~/.config/powershell/profile.ps1 mirroring the default bashrc in + # PowerShell-native syntax. profile.ps1 (CurrentUserAllHosts) is loaded for + # every interactive pwsh; the bashrc re-exec is only a default-shell switch. + mkdir -p "$HOME/.config/powershell" || return 1 + cat > "$HOME/.config/powershell/profile.ps1" <<-'PWSHRC' || return 1 + # squarebox pwsh config (experimental) — mirrors ~/.bashrc + $env:EDITOR = 'nano' + $env:PATH = "$HOME/.local/bin:$env:PATH" + + function ls { eza --icons @args } + function ll { eza -la --icons @args } + function lsa { eza -a --icons @args } + function lt { eza --tree --level=2 --long --icons --git @args } + function lta { lt -a @args } + function cat { bat --paging=never @args } + function ff { fzf --preview 'bat --style=numbers --color=always {}' @args } + function eff { & $env:EDITOR (ff) @args } + function .. { Set-Location .. } + function ... { Set-Location ../.. } + function .... { Set-Location ../../.. } + function g { git @args } + function gcm { git commit -m @args } + function gcam { git commit -a -m @args } + function gcad { git commit -a --amend @args } + + Invoke-Expression (starship init powershell | Out-String) + Invoke-Expression (zoxide init powershell | Out-String) + if (Get-Command mise -ErrorAction SilentlyContinue) { + Invoke-Expression (mise activate powershell | Out-String) + } + if (Test-Path "$HOME/.config/powershell/squarebox-selections.ps1") { + . "$HOME/.config/powershell/squarebox-selections.ps1" + } + if (Test-Path /usr/local/lib/squarebox/motd.sh) { + & /usr/local/lib/squarebox/motd.sh + } + PWSHRC + [ -f "$HOME/.config/powershell/profile.ps1" ] || return 1 + + _refresh_pwsh_selections || return 1 +} + +install_pwsh() { + run_with_spinner "Installing PowerShell..." _install_pwsh_inner +} + case "$shell_choice" in zsh) if install_zsh; then + rm -f ~/.squarebox-use-fish ~/.squarebox-use-pwsh touch ~/.squarebox-use-zsh - rm -f ~/.squarebox-use-fish printf 'zsh\n' > "$SHELL_CONFIG" echo "Zsh will take over at the end of this setup (next interactive shell)." else @@ -2290,16 +2458,26 @@ case "$shell_choice" in ;; fish) if install_fish; then + rm -f ~/.squarebox-use-zsh ~/.squarebox-use-pwsh touch ~/.squarebox-use-fish - rm -f ~/.squarebox-use-zsh printf 'fish\n' > "$SHELL_CONFIG" echo "Fish will take over at the end of this setup (next interactive shell)." else record_failure "Fish installation failed; prior shell Selection was preserved" fi ;; + pwsh) + if install_pwsh; then + rm -f ~/.squarebox-use-zsh ~/.squarebox-use-fish + touch ~/.squarebox-use-pwsh + printf 'pwsh\n' > "$SHELL_CONFIG" + echo "PowerShell will take over at the end of this setup (next interactive shell)." + else + record_failure "PowerShell installation failed; prior shell Selection was preserved" + fi + ;; bash|*) - rm -f ~/.squarebox-use-zsh ~/.squarebox-use-fish + rm -f ~/.squarebox-use-zsh ~/.squarebox-use-fish ~/.squarebox-use-pwsh printf 'bash\n' > "$SHELL_CONFIG" ;; esac @@ -2310,6 +2488,10 @@ if ! _refresh_fish_selections; then record_failure "Fish selection aliases could not be refreshed" fi +if ! _refresh_pwsh_selections; then + record_failure "PowerShell selection aliases could not be refreshed" +fi + if [ "$SB_FAILURES" -gt 0 ]; then echo >&2 echo "squarebox setup incomplete: $SB_FAILURES operation(s) failed." >&2 diff --git a/tests/test-provision-install-failures.sh b/tests/test-provision-install-failures.sh index 0ed9787..867857f 100755 --- a/tests/test-provision-install-failures.sh +++ b/tests/test-provision-install-failures.sh @@ -16,10 +16,22 @@ assert_true() { if eval "$1"; then ok "$2"; else not_ok "$2"; fi; } # observed success. FIXTURE_BIN="$TMP/bin" mkdir -p "$FIXTURE_BIN" -for utility in awk bash chmod grep jq mkdir mktemp rm sed sha256sum touch tr; do +for utility in awk bash chmod grep jq mkdir mktemp rm sed sha256sum touch tr uname; do ln -s "$(command -v "$utility")" "$FIXTURE_BIN/$utility" done +# PowerShell fixture: a real release-like asset whose SHA-256 the fixture +# metadata declares. Name matches the runner architecture so the installer's +# asset selection resolves on both amd64 and arm64 CI. +PWSH_FAKE_DEB="$TMP/pwsh-fixture.deb" +printf 'fixture powershell deb\n' > "$PWSH_FAKE_DEB" +PWSH_FAKE_SHA=$(sha256sum "$PWSH_FAKE_DEB" | awk '{print $1}') +case "$(uname -m)" in + x86_64|amd64) PWSH_FAKE_ASSET="powershell_7.6.5-1.deb_amd64.deb" ;; + aarch64|arm64) PWSH_FAKE_ASSET="powershell_7.6.5-1.deb_arm64.deb" ;; + *) PWSH_FAKE_ASSET="" ;; +esac + # Fault-inject setup-owned atomic file publication without changing setup's # public entry point. `cat` can emit a matching heredoc's first line and then # fail (a truncated staging write); `mv` can fail immediately before publish. @@ -97,8 +109,18 @@ cat > "$FIXTURE_LIB" <<-'TOOL_LIB' esac return 0 fi - if [ "${SB_METADATA_MODE:-fail}" = success ]; then + if [ "${SB_METADATA_MODE:-fail}" = success ] || [ "${SB_METADATA_MODE:-fail}" = digest-wrong ]; then case "$url" in + */repos/PowerShell/PowerShell/releases/latest) + local pwsh_digest + pwsh_digest="${PWSH_FAKE_SHA:-0000000000000000000000000000000000000000000000000000000000000000}" + if [ "${SB_METADATA_MODE:-fail}" = digest-wrong ]; then + pwsh_digest="0000000000000000000000000000000000000000000000000000000000000000" + fi + printf '{"tag_name":"v7.6.5","assets":[{"name":"%s","digest":"sha256:%s","browser_download_url":"https://github.com/PowerShell/PowerShell/releases/download/v7.6.5/%s"}]}\n' \ + "$PWSH_FAKE_ASSET" "$pwsh_digest" "$PWSH_FAKE_ASSET" + return 0 + ;; */repos/LazyVim/starter) printf '{"default_branch":"lazyvim-default"}\n' ;; */repos/LazyVim/starter/commits/lazyvim-default) printf '{"sha":"4444444444444444444444444444444444444444"}\n' ;; */repos/ohmyzsh/ohmyzsh) printf '{"default_branch":"omz-default"}\n' ;; @@ -142,7 +164,12 @@ run_selected_section() { MISE_CALLS="$case_dir/mise.calls" \ MISE_MODE="${MISE_MODE:-}" \ MISE_RUST_INSTALLED="$case_dir/rust.installed" \ + APT_CALLS="$case_dir/apt.calls" \ FAKE_GUM_SELECTION="$selection" \ + PWSH_FAKE_DEB="$PWSH_FAKE_DEB" \ + PWSH_FAKE_SHA="$PWSH_FAKE_SHA" \ + PWSH_FAKE_ASSET="$PWSH_FAKE_ASSET" \ + FIXTURE_BIN="$FIXTURE_BIN" \ PATH="$FIXTURE_BIN" \ /usr/bin/script -qec "/bin/bash '$ROOT/setup.sh' --rerun '$section'" /dev/null \ >"$case_dir/setup.out" 2>&1 @@ -315,6 +342,18 @@ ZSH cat > "$FIXTURE_BIN/curl" <<-'CURL' #!/bin/bash printf 'curl %s\n' "$*" >> "$NETWORK_CALLS" + case "$*" in + *"$PWSH_FAKE_ASSET"*) + out="" + while [ "$#" -gt 0 ]; do + if [ "$1" = -o ]; then shift; out="$1"; fi + shift + done + [ -n "$out" ] || exit 23 + cat "$PWSH_FAKE_DEB" > "$out" + exit 0 + ;; + esac exit 23 CURL cat > "$FIXTURE_BIN/git" <<-'GIT' @@ -368,6 +407,31 @@ cat > "$FIXTURE_BIN/git" <<-'GIT' GIT chmod +x "$FIXTURE_BIN/zsh" "$FIXTURE_BIN/curl" "$FIXTURE_BIN/git" +# PowerShell installs through apt_install (tzdata hold + update + install). +# Fake the passwordless sudo contract and record the install; on a successful +# `apt-get install`, expose an observed `pwsh` so the installer's post-install +# probe and marker/Selection commit can complete without dpkg. +cat > "$FIXTURE_BIN/sudo" <<-'SUDO' + #!/bin/bash + [ "${1:-}" = -n ] && shift + exec "$@" +SUDO +cat > "$FIXTURE_BIN/apt-get" <<-'APTG' + #!/bin/bash + printf 'apt-get %s\n' "$*" >> "$APT_CALLS" + case "${1:-}" in + update) exit 0 ;; + install) + printf '#!/bin/bash\nexit 0\n' > "$FIXTURE_BIN/pwsh" + chmod +x "$FIXTURE_BIN/pwsh" + exit 0 + ;; + *) exit 49 ;; + esac +APTG +printf '#!/bin/bash\nexit 0\n' > "$FIXTURE_BIN/apt-mark" +chmod +x "$FIXTURE_BIN/sudo" "$FIXTURE_BIN/apt-get" "$FIXTURE_BIN/apt-mark" + # LazyVim uses an immutable default-branch SHA, resolved before compiler or # Managed-home mutation, and verifies HEAD before publishing the starter tree. printf '#!/bin/bash\nexit 0\n' > "$FIXTURE_BIN/nvim" @@ -440,5 +504,44 @@ run_selected_section shell 'zsh (experimental)' "$ZSH_DIRTY_CASE" 42 success exp assert_true "[ \"\$(cat '$ZSH_DIRTY_CASE/setup.rc')\" -ne 0 ] && grep -q 'preserving local changes' '$ZSH_DIRTY_CASE/setup.out'" \ "Zsh reconciliation preserves and refuses to mix local source changes" +# PowerShell resolves GitHub release metadata (and its release-asset SHA-256) +# before downloading or installing. A metadata failure must abort before any +# download or Managed-home mutation, a digest mismatch must reject the asset, +# and only a verified install may activate pwsh and commit the Selection. +if [ -n "$PWSH_FAKE_ASSET" ]; then + PWSH_METADATA_FAILURE_CASE="$TMP/pwsh-metadata-failure" + run_selected_section shell 'powershell (experimental)' "$PWSH_METADATA_FAILURE_CASE" + assert_true "[ \"\$(cat '$PWSH_METADATA_FAILURE_CASE/setup.rc')\" -ne 0 ]" \ + "PowerShell setup propagates GitHub metadata failure" + assert_true "[ ! -e '$PWSH_METADATA_FAILURE_CASE/network.calls' ]" \ + "PowerShell metadata failure aborts before any download" + assert_true "[ ! -e '$PWSH_METADATA_FAILURE_CASE/home/.squarebox-use-pwsh' ] && [ ! -e '$PWSH_METADATA_FAILURE_CASE/state/shell' ] && [ ! -e '$PWSH_METADATA_FAILURE_CASE/home/.config/powershell/profile.ps1' ]" \ + "PowerShell metadata failure aborts before Managed-home mutation or Selection commit" + assert_true "grep -q 'fixture GitHub metadata failure' '$PWSH_METADATA_FAILURE_CASE/setup.out'" \ + "PowerShell metadata failure remains visible" + + PWSH_DIGEST_MISMATCH_CASE="$TMP/pwsh-digest-mismatch" + run_selected_section shell 'powershell (experimental)' "$PWSH_DIGEST_MISMATCH_CASE" 42 digest-wrong + assert_true "[ \"\$(cat '$PWSH_DIGEST_MISMATCH_CASE/setup.rc')\" -ne 0 ]" \ + "PowerShell digest mismatch rejects the release asset" + assert_true "grep -q 'curl -fsSL' '$PWSH_DIGEST_MISMATCH_CASE/network.calls'" \ + "PowerShell digest check runs after download" + assert_true "grep -q 'SHA-256 mismatch' '$PWSH_DIGEST_MISMATCH_CASE/setup.out'" \ + "PowerShell digest mismatch remains visible" + assert_true "[ ! -e '$PWSH_DIGEST_MISMATCH_CASE/home/.squarebox-use-pwsh' ] && [ ! -e '$PWSH_DIGEST_MISMATCH_CASE/state/shell' ] && [ ! -e '$PWSH_DIGEST_MISMATCH_CASE/home/.config/powershell/profile.ps1' ]" \ + "PowerShell digest mismatch cannot activate the shell or commit a Selection" + + PWSH_SUCCESS_CASE="$TMP/pwsh-success" + run_selected_section shell 'powershell (experimental)' "$PWSH_SUCCESS_CASE" 42 success + assert_true "[ \"\$(cat '$PWSH_SUCCESS_CASE/setup.rc')\" -eq 0 ] && [ \"\$(cat '$PWSH_SUCCESS_CASE/state/shell')\" = pwsh ]" \ + "PowerShell setup succeeds from verified release metadata and commits pwsh" + assert_true "[ -e '$PWSH_SUCCESS_CASE/home/.squarebox-use-pwsh' ] && [ ! -e '$PWSH_SUCCESS_CASE/home/.squarebox-use-zsh' ] && [ ! -e '$PWSH_SUCCESS_CASE/home/.squarebox-use-fish' ]" \ + "PowerShell activation marker is exclusive" + assert_true "grep -Fq '# squarebox pwsh config' '$PWSH_SUCCESS_CASE/home/.config/powershell/profile.ps1' && grep -Fq 'squarebox-selections.ps1' '$PWSH_SUCCESS_CASE/home/.config/powershell/profile.ps1'" \ + "PowerShell generates a managed profile that sources the selection snippet" + assert_true "grep -Fq 'apt-get install' '$PWSH_SUCCESS_CASE/apt.calls'" \ + "PowerShell installs through the verified local .deb" +fi + printf '1..%d\n' "$((PASS + FAIL))" [ "$FAIL" -eq 0 ] diff --git a/tests/test-provision-state.sh b/tests/test-provision-state.sh index d978e1d..e9886f3 100755 --- a/tests/test-provision-state.sh +++ b/tests/test-provision-state.sh @@ -145,6 +145,30 @@ else fi rm -f "$BIN/cc" "$STATE/editors" "$STATE/nvim-lazyvim" +# PowerShell reconciles when pwsh or its managed profile is missing from the Box. +printf 'pwsh\n' > "$STATE/shell" +if PATH="$BIN" box_reconcile_needed; then + ok "missing Box-tier pwsh requires reconciliation" +else + not_ok "missing Box-tier pwsh requires reconciliation" +fi +printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN/pwsh" +chmod +x "$BIN/pwsh" +if PATH="$BIN" box_reconcile_needed; then + ok "pwsh Selection reconciles a missing managed profile" +else + not_ok "pwsh Selection reconciles a missing managed profile" +fi +mkdir -p "$HOME_DIR/.config/powershell" +printf '# squarebox pwsh config (experimental)\n' > "$HOME_DIR/.config/powershell/profile.ps1" +if PATH="$BIN" box_reconcile_needed; then + not_ok "observed pwsh profile needs no reconciliation" +else + ok "observed pwsh profile needs no reconciliation" +fi +rm -f "$BIN/pwsh" "$STATE/shell" +rm -rf "$HOME_DIR/.config/powershell" + # Exercise the real non-interactive reconcile path with a fixture tmux as the # observed package and a fixture tool library (this path performs no network). FIXTURE_LIB="$TMP/tool-lib.sh"