From 8e0f3c022c05f23ceee0aea40de9007fde32bc7a Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Wed, 13 May 2026 14:55:20 -0500 Subject: [PATCH 1/8] fix(windows): re-activate fnm in piped scope before each npm site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When bootstrap.ps1 is executed via `irm | iex`, the fnm activation done in the Node section does not persist into the child PowerShell scopes where later sections invoke npm. The first downstream npm call fails with `The term 'npm' is not recognized…`. Adds an EnsureFnmActivated helper and calls it immediately before each post-Node npm site: - corepack pnpm fallback - Claude Code migration cleanup (npm ls -g / npm uninstall -g) - Codex CLI install (npm install -g @openai/codex@latest) The guard is idempotent and a no-op when fnm is not installed. Closes #3 --- bootstrap.ps1 | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 677d513..67dd845 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -88,6 +88,16 @@ function RefreshPath { $env:Path = ($merged -join ';') } +function EnsureFnmActivated { + # Re-evaluate fnm's PowerShell env block in the current scope. Required + # before any `npm` / `npx` call when the script is run via `irm | iex`, + # because fnm activation does not persist across the piped child scopes. + # Idempotent: no-op when fnm is not installed. + if (Get-Command fnm -ErrorAction SilentlyContinue) { + fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression + } +} + function PrependPathIfExists([string]$pathToAdd) { if (-not (Test-Path $pathToAdd)) { return } if (($env:Path -split ';') -contains $pathToAdd) { return } @@ -239,6 +249,7 @@ if (Get-Command corepack -ErrorAction SilentlyContinue) { corepack prepare pnpm@latest --activate 2>&1 | Out-Null } else { Write-Host "corepack not found — falling back to npm install -g pnpm" -ForegroundColor Yellow + EnsureFnmActivated npm install -g pnpm if ($LASTEXITCODE -ne 0) { throw "npm install -g pnpm failed with exit code $LASTEXITCODE." @@ -321,6 +332,7 @@ Section "Claude Code" # Migration cleanup: if older runs left Claude Code installed via npm or WinGet, # remove those package-manager copies so the auto-updating native binary is the # sole `claude` on PATH. +EnsureFnmActivated if (Get-Command npm -ErrorAction SilentlyContinue) { $npmClaude = (npm ls -g @anthropic-ai/claude-code --depth=0 2>$null | Out-String) if ($npmClaude -match '@anthropic-ai/claude-code@') { @@ -355,9 +367,7 @@ Section "Codex CLI" # Codex CLI has no winget package yet, so npm remains the official install path. # Always target @latest so re-runs upgrade stale installs too. # https://developers.openai.com/codex/cli -if (Get-Command fnm -ErrorAction SilentlyContinue) { - fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression -} +EnsureFnmActivated Write-Host "Installing/upgrading: @openai/codex@latest" npm install -g @openai/codex@latest if ($LASTEXITCODE -ne 0) { From b68594ed8721de0741f2ea3b32bb96f3e0999e58 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Wed, 13 May 2026 14:55:41 -0500 Subject: [PATCH 2/8] feat(macos): harden bootstrap against pre-existing brew Node drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After two real-Mac incidents on 2026-05-13 where ad-hoc `brew install node` (v25/v26, Current-line) shadowed fnm-managed LTS and broke `claude` and `qmd` via better-sqlite3 ABI mismatch, harden macos/bootstrap.sh and zshrc.snippet.sh against that whole class of drift. New behavior: - Pre-existing brew Node detection. If `brew list` reports node not on the pinned major, prompt to `brew unlink node` (or auto-unlink with --force-relink for CI / non-interactive reruns). 60s prompt timeout. - Plan B `--brew-only` flag. Installs node@22 via brew and force-links it, skipping fnm entirely. Mutually exclusive with explicit --fnm. - Homebrew path ownership pre-flight. Probes /usr/local/share/zsh*, /usr/local/var/homebrew, /usr/local/Cellar, and the /opt/homebrew/* equivalents for writability. On the first broken path, requests sudo once up front and chowns only the broken paths to $USER:staff. - `--restore-globals FILE` flag. After Node is installed, reinstalls each npm package named in FILE (one per line; blank lines and `#` comments ignored). Failures are reported with captured stderr but do not abort the bootstrap. - Non-LTS drift guard in zshrc snippet. Warns on every interactive shell startup when `node -v` is not the pinned LTS major (default 22), with the recovery command inline. Catches both odd-major Current releases and even-but-Current drift (v26) — the broader rule that #5's AC#4 narrowed to "odd-line only" missed in the original incident. Code is split into pure helpers + side-effecting glue: - macos/lib/node-utils.sh — is_non_lts_node_version, parse_globals_file - macos/lib/cli.sh — parse_flags - macos/bootstrap.sh — preflight_brew_paths, remediate_existing_brew_node, restore_npm_globals Tests (25 passing): plain-bash tests/run.sh + per-helper *.test.sh. Side-effecting blocks are not unit-tested; gated by real-Mac runs on mbp13 and the primary dev Mac per #5 AC#8. README adds: macOS flags reference and a "Migrating a Mac that was already set up ad-hoc" recovery section with copy-pasteable commands. Closes #5 --- README.md | 39 +++++ macos/bootstrap.sh | 229 ++++++++++++++++++++++++----- macos/lib/cli.sh | 80 ++++++++++ macos/lib/node-utils.sh | 36 +++++ macos/zshrc.snippet.sh | 20 +++ tests/macos/cli.test.sh | 75 ++++++++++ tests/macos/globals-parser.test.sh | 67 +++++++++ tests/macos/node-utils.test.sh | 38 +++++ tests/run.sh | 80 ++++++++++ 9 files changed, 630 insertions(+), 34 deletions(-) create mode 100644 macos/lib/cli.sh create mode 100644 macos/lib/node-utils.sh create mode 100644 tests/macos/cli.test.sh create mode 100644 tests/macos/globals-parser.test.sh create mode 100644 tests/macos/node-utils.test.sh create mode 100755 tests/run.sh diff --git a/README.md b/README.md index 0bee5f1..7d03ba1 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,45 @@ macOS extras included: One command. Paste it in your terminal. Walk away. Come back to a configured development machine. +#### macOS flags + +```bash +bash macos/bootstrap.sh [--brew-only] [--fnm] [--force-relink] [--restore-globals FILE] +``` + +- `--brew-only` — install `node@${PIN}` directly via Homebrew, skip fnm. Use when you want a stable, brew-only Node without an fnm shell hook. Mutually exclusive with `--fnm`. +- `--fnm` — explicit default. LTS Node via fnm. +- `--force-relink` — auto-`brew unlink node` if a non-pinned brew Node is detected. Safe for CI / non-interactive reruns; without it the script prompts before unlinking. +- `--restore-globals FILE` — after Node is installed, reinstall every npm package named in `FILE` (one per line; blank lines and `# comments` ignored). Failures are reported but do not abort the bootstrap. + +#### Migrating a Mac that was already set up ad-hoc + +If a Mac already has `brew install node` from a prior ad-hoc setup, the Current-line Node it ships (e.g. v25 / v26) will shadow `node@${PIN}` / fnm and break `claude` and other native modules via `better-sqlite3` ABI mismatch. Recovery is mechanical: + +```bash +# 1. Save your existing globals so you can restore them after switching Node. +npm ls -g --depth=0 --json | jq -r '.dependencies | keys[]' > ~/dev-bootstrap-globals.txt + +# 2. Install the pinned LTS and force-link it. +brew install node@22 +brew unlink node && brew link --force --overwrite node@22 + +# 3. Reinstall the globals on the pinned Node. +xargs -I{} npm install -g {}@latest < ~/dev-bootstrap-globals.txt + +# 4. Drop the old keg once `node -v` reports v22. +brew uninstall node || true +``` + +Or do all of it in one bootstrap pass: + +```bash +npm ls -g --depth=0 --json | jq -r '.dependencies | keys[]' > ~/dev-bootstrap-globals.txt +bash macos/bootstrap.sh --brew-only --force-relink --restore-globals ~/dev-bootstrap-globals.txt +``` + +After install, future `node -v` drift to a non-pinned major triggers a one-line shell-startup warning from the appended zsh snippet with the exact recovery command inline. +

dev-bootstrap installation architecture

diff --git a/macos/bootstrap.sh b/macos/bootstrap.sh index 5ca78fe..5ac4c38 100755 --- a/macos/bootstrap.sh +++ b/macos/bootstrap.sh @@ -2,7 +2,30 @@ set -euo pipefail # macOS bootstrap entrypoint -# Installs: Xcode CLT, Homebrew, git, curl, fnm (Node), uv (Python), bun, Claude Code, Codex CLI +# Installs: Xcode CLT, Homebrew, git, curl, fnm or node@22 (per --brew-only), +# uv (Python), bun, Claude Code, Codex CLI. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Default flag state before parse_flags runs, so any helper that reads these +# globals stays safe under `set -u` even if parse_flags is reordered later. +BREW_ONLY=0 +USE_FNM=1 +FORCE_RELINK=0 +RESTORE_GLOBALS_FILE="" + +# shellcheck source=macos/lib/cli.sh +source "$SCRIPT_DIR/lib/cli.sh" +# shellcheck source=macos/lib/node-utils.sh +source "$SCRIPT_DIR/lib/node-utils.sh" + +# parse_flags may exit 2 on --help; bubble that up cleanly. +if ! parse_flags "$@"; then + ec=$? + if [[ "$ec" -eq 2 ]]; then exit 0; fi + exit "$ec" +fi MARKER_BEGIN="# ---- dev-bootstrap (macos) ----" MARKER_END="# ------------------------------" @@ -19,7 +42,6 @@ require_cmd() { } ensure_line_in_file_once() { - # Usage: ensure_line_in_file_once local file="$1" local line="$2" mkdir -p "$(dirname "$file")" @@ -47,12 +69,138 @@ ensure_snippet_in_zshrc_once() { } >> "$zshrc" } +# Pre-flight ownership repair for Homebrew-touched paths that commonly cause +# `compinit` prompts and `brew install` failures (rb_sysopen permission denied) +# after ad-hoc setup. Runs read-only probes first; if any path is broken, +# requests sudo once up front, then repairs only the broken paths. +preflight_brew_paths() { + local paths=( + /usr/local/share/zsh + /usr/local/share/zsh/site-functions + /usr/local/var/homebrew + /usr/local/Cellar + /opt/homebrew/share/zsh + /opt/homebrew/share/zsh/site-functions + /opt/homebrew/var/homebrew + /opt/homebrew/Cellar + ) + local broken=() + for p in "${paths[@]}"; do + [[ -e "$p" ]] || continue + [[ -w "$p" ]] || broken+=("$p") + done + if [[ ${#broken[@]} -eq 0 ]]; then + return 0 + fi + echo "Found ${#broken[@]} Homebrew path(s) the current user can't write to:" >&2 + printf ' %s\n' "${broken[@]}" >&2 + echo "Requesting sudo once to chown them to $USER:staff..." >&2 + sudo -v + local failed=() + for p in "${broken[@]}"; do + if ! sudo chown -R "$USER:staff" "$p"; then + failed+=("$p") + fi + done + if [[ ${#failed[@]} -gt 0 ]]; then + echo "chown failed for:" >&2 + printf ' %s\n' "${failed[@]}" >&2 + return 1 + fi +} + +# Detect a pre-existing `brew install node` (Current-line node formula). If +# present and non-matching the pin, either prompt to unlink (interactive) or +# unlink unconditionally (--force-relink) so it doesn't shadow fnm/node@22. +remediate_existing_brew_node() { + if ! command -v brew >/dev/null 2>&1; then + return 0 + fi + if ! brew list --formula 2>/dev/null | grep -qx 'node'; then + return 0 + fi + local installed + installed="$(brew list --versions node 2>/dev/null | awk '{print $2}')" + if [[ -z "$installed" ]]; then + return 0 + fi + local major="${installed%%.*}" + if [[ "$major" == "$PINNED_NODE_LTS_MAJOR" ]]; then + return 0 + fi + echo "Detected pre-existing brew Node v$installed (pin is v$PINNED_NODE_LTS_MAJOR)." >&2 + _unlink_brew_node() { + if ! brew unlink node >/dev/null; then + echo " brew unlink node failed. Resolve manually (brew lock contention? partial install?) and re-run." >&2 + exit 1 + fi + } + if [[ "${FORCE_RELINK:-0}" == "1" ]]; then + echo " --force-relink set: running brew unlink node..." >&2 + _unlink_brew_node + return 0 + fi + echo " This will shadow $( [[ "${BREW_ONLY:-0}" == "1" ]] && echo "node@${PINNED_NODE_LTS_MAJOR}" || echo "fnm-managed Node" )." >&2 + printf " Run 'brew unlink node' now? [y/N, 60s timeout] " >&2 + local ans="" + if ! read -r -t 60 ans; then + echo >&2 + echo " Prompt timed out. Re-run with --force-relink or unlink manually." >&2 + exit 1 + fi + case "$ans" in + y|Y|yes) + _unlink_brew_node + ;; + *) + echo "Aborting. Re-run with --force-relink or unlink manually." >&2 + exit 1 + ;; + esac +} + +# Apply a globals-restore list once Node is on PATH. Failures are reported but +# do not abort the bootstrap; each failing install's stderr is preserved so the +# user can diagnose registry / proxy / permission issues from the summary. +restore_npm_globals() { + local file="$1" + [[ -f "$file" ]] || return 0 + local installed=() skipped=() failed=() + local failure_log + failure_log="$(mktemp -t globals-restore.XXXXXX.log)" + local pkg + while IFS= read -r pkg; do + [[ -n "$pkg" ]] || continue + if npm list -g --depth=0 "$pkg" >/dev/null 2>&1; then + skipped+=("$pkg") + continue + fi + if npm install -g "${pkg}@latest" >/dev/null 2>>"$failure_log"; then + installed+=("$pkg") + else + failed+=("$pkg") + printf -- '--- %s failed ---\n' "$pkg" >> "$failure_log" + fi + done < <(parse_globals_file "$file") + echo + echo "Globals restore summary:" + echo " installed: ${#installed[@]} (${installed[*]:-none})" + echo " skipped: ${#skipped[@]} (${skipped[*]:-none})" + echo " failed: ${#failed[@]} (${failed[*]:-none})" + if [[ ${#failed[@]} -gt 0 ]]; then + echo + echo " Failure details ($failure_log):" + sed 's/^/ /' "$failure_log" >&2 + else + rm -f "$failure_log" + fi +} + log "Preflight: Xcode Command Line Tools" if ! xcode-select -p >/dev/null 2>&1; then echo "Xcode Command Line Tools not found. Triggering install..." xcode-select --install || true echo "Waiting for Command Line Tools installation to complete..." - # Poll until installed (user must complete GUI prompt) until xcode-select -p >/dev/null 2>&1; do sleep 5 done @@ -64,7 +212,6 @@ if ! command -v brew >/dev/null 2>&1; then /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" fi -# Initialize brew in future shells (idempotent) if [[ -x /opt/homebrew/bin/brew ]]; then ensure_line_in_file_once "$HOME/.zprofile" 'eval "$(/opt/homebrew/bin/brew shellenv)"' eval "$(/opt/homebrew/bin/brew shellenv)" @@ -72,52 +219,63 @@ elif [[ -x /usr/local/bin/brew ]]; then ensure_line_in_file_once "$HOME/.zprofile" 'eval "$(/usr/local/bin/brew shellenv)"' eval "$(/usr/local/bin/brew shellenv)" else - # If brew is installed somewhere else, just require it and continue. require_cmd brew fi +log "Pre-flight Homebrew path ownership" +preflight_brew_paths + +log "Pre-existing brew Node remediation" +remediate_existing_brew_node + log "Base tools (git, curl)" -# Idempotent: brew will verify or install. brew list git >/dev/null 2>&1 || brew install git brew list curl >/dev/null 2>&1 || brew install curl log "Terminal UX (lazygit, yazi)" -# lazygit: https://github.com/jesseduffield/lazygit brew list lazygit >/dev/null 2>&1 || brew install lazygit - -# yazi: https://yazi-rs.github.io/docs/installation/#homebrew brew list yazi >/dev/null 2>&1 || brew install yazi -log "Node via fnm" -brew list fnm >/dev/null 2>&1 || brew install fnm +if [[ "${BREW_ONLY:-0}" == "1" ]]; then + log "Node via brew node@${PINNED_NODE_LTS_MAJOR} (Plan B, --brew-only)" + brew list "node@${PINNED_NODE_LTS_MAJOR}" >/dev/null 2>&1 || brew install "node@${PINNED_NODE_LTS_MAJOR}" + # Only re-link when something other than the pinned keg owns the current node binary, + # so reruns produce zero state mutations once the link is correct. + active_node="$(readlink "$(brew --prefix)/bin/node" 2>/dev/null || true)" + if [[ "$active_node" != *"node@${PINNED_NODE_LTS_MAJOR}/"* ]]; then + brew link --force --overwrite "node@${PINNED_NODE_LTS_MAJOR}" + fi + SNIPPET="$REPO_ROOT/macos/zshrc.snippet.sh" + ensure_snippet_in_zshrc_once "$SNIPPET" + require_cmd node + require_cmd npm + node -v + npm -v +else + log "Node via fnm" + brew list fnm >/dev/null 2>&1 || brew install fnm -# Ensure fnm activation + PATH in zsh -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -SNIPPET="$REPO_ROOT/macos/zshrc.snippet.sh" -require_cmd fnm -ensure_snippet_in_zshrc_once "$SNIPPET" - -# Activate fnm in current shell -# shellcheck disable=SC2046 -if fnm env --use-on-cd >/dev/null 2>&1; then - eval "$(fnm env --use-on-cd)" -fi + SNIPPET="$REPO_ROOT/macos/zshrc.snippet.sh" + require_cmd fnm + ensure_snippet_in_zshrc_once "$SNIPPET" -fnm install --lts -fnm default lts-latest -require_cmd node -require_cmd npm -node -v -npm -v + # shellcheck disable=SC2046 + if fnm env --use-on-cd >/dev/null 2>&1; then + eval "$(fnm env --use-on-cd)" + fi + + fnm install --lts + fnm default lts-latest + require_cmd node + require_cmd npm + node -v + npm -v +fi log "Python via uv" if ! command -v uv >/dev/null 2>&1; then curl -LsSf https://astral.sh/uv/install.sh | sh fi - -# Ensure PATH contains uv default install location -# (actual PATH injection is handled by zsh snippet as well) require_cmd uv uv --version uv python install 3.12 @@ -134,7 +292,6 @@ bun --version log "Claude Code" if ! command -v claude >/dev/null 2>&1; then - # Per official docs for macOS/Linux curl -fsSL https://claude.ai/install.sh | bash fi if command -v claude >/dev/null 2>&1; then @@ -144,11 +301,15 @@ else fi log "Codex CLI" -# Requires npm from the fnm-managed node. npm list -g --depth=0 @openai/codex >/dev/null 2>&1 || npm install -g @openai/codex require_cmd codex codex --version +if [[ -n "${RESTORE_GLOBALS_FILE:-}" ]]; then + log "Restoring npm globals from $RESTORE_GLOBALS_FILE" + restore_npm_globals "$RESTORE_GLOBALS_FILE" +fi + log "Finish" echo "Open a NEW terminal so .zprofile/.zshrc changes load." echo "Then run (interactive auth):" diff --git a/macos/lib/cli.sh b/macos/lib/cli.sh new file mode 100644 index 0000000..3b4a65c --- /dev/null +++ b/macos/lib/cli.sh @@ -0,0 +1,80 @@ +# shellcheck shell=bash +# CLI flag parsing for macos/bootstrap.sh. +# parse_flags sets the following globals (all initialized to defaults on every call): +# BREW_ONLY "0" / "1" — install node@22 via brew, skip fnm path +# USE_FNM "0" / "1" — install LTS via fnm (default mode) +# FORCE_RELINK "0" / "1" — non-interactive remediation of pre-existing brew Node +# RESTORE_GLOBALS_FILE path — file of npm package names to reinstall (or empty) +# Returns 0 on success, 1 on usage error (unknown flag, missing arg, mutex violation, +# or --restore-globals pointing at a missing file). + +parse_flags() { + BREW_ONLY=0 + USE_FNM=1 + FORCE_RELINK=0 + RESTORE_GLOBALS_FILE="" + + local brew_only_explicit=0 + local fnm_explicit=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --brew-only) + BREW_ONLY=1 + USE_FNM=0 + brew_only_explicit=1 + ;; + --fnm) + BREW_ONLY=0 + USE_FNM=1 + fnm_explicit=1 + ;; + --force-relink) + FORCE_RELINK=1 + ;; + --restore-globals) + shift + if [[ $# -eq 0 ]]; then + echo "--restore-globals requires a file path" >&2 + return 1 + fi + if [[ ! -f "$1" ]]; then + echo "--restore-globals: file not found: $1" >&2 + return 1 + fi + RESTORE_GLOBALS_FILE="$1" + ;; + -h|--help) + cat <<'USAGE' +Usage: macos/bootstrap.sh [options] + + --brew-only Install node@22 via Homebrew; skip fnm. + Mutually exclusive with --fnm. + --fnm Install LTS Node via fnm (default). + Mutually exclusive with --brew-only. + --force-relink Auto-resolve a pre-existing `brew install node` + by `brew unlink node` without prompting. Safe + for CI / non-interactive reruns. + --restore-globals FILE After Node is installed, reinstall each npm + package named in FILE (one per line, blank lines + and `# comments` ignored). Failures are reported + but do not abort the bootstrap. + -h, --help Show this help and exit. +USAGE + return 2 + ;; + *) + echo "unknown argument: $1" >&2 + return 1 + ;; + esac + shift + done + + if [[ "$brew_only_explicit" == "1" && "$fnm_explicit" == "1" ]]; then + echo "--brew-only and --fnm are mutually exclusive — pick one" >&2 + return 1 + fi + + return 0 +} diff --git a/macos/lib/node-utils.sh b/macos/lib/node-utils.sh new file mode 100644 index 0000000..fd57ddb --- /dev/null +++ b/macos/lib/node-utils.sh @@ -0,0 +1,36 @@ +# shellcheck shell=bash +# Pure helpers used by macos/bootstrap.sh and macos/zshrc.snippet.sh. +# No side effects; safe to source from interactive shells. + +PINNED_NODE_LTS_MAJOR="${PINNED_NODE_LTS_MAJOR:-22}" + +is_non_lts_node_version() { + # Returns 0 when the given Node version's major does not match the pinned + # LTS major. Returns 1 otherwise, including for malformed or empty input. + # Accepts "vX.Y.Z" or "X.Y.Z". Optional second arg overrides the pinned + # major (defaults to $PINNED_NODE_LTS_MAJOR, default 22). + local raw="${1:-}" + raw="${raw#v}" + local major="${raw%%.*}" + local pin="${2:-$PINNED_NODE_LTS_MAJOR}" + [[ "$major" =~ ^[0-9]+$ ]] || return 1 + [[ "$pin" =~ ^[0-9]+$ ]] || return 1 + (( major != pin )) +} + +parse_globals_file() { + # Reads a globals-list file (one npm package name per line) and emits each + # name on stdout, skipping blank lines and stripping `#` comments (both + # whole-line comments and trailing comments). Returns 1 if the file is + # missing or no path was given. + local file="${1:-}" + [[ -n "$file" && -f "$file" ]] || return 1 + awk ' + { + sub(/#.*$/, "") + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (length($0) > 0) print + } + ' "$file" +} diff --git a/macos/zshrc.snippet.sh b/macos/zshrc.snippet.sh index e982167..222a940 100644 --- a/macos/zshrc.snippet.sh +++ b/macos/zshrc.snippet.sh @@ -7,3 +7,23 @@ export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" if command -v fnm >/dev/null 2>&1; then eval "$(fnm env --use-on-cd)" fi + +# Non-LTS Node guard. Warns on shell startup when `node -v` is not on the +# pinned LTS major (default 22). Catches both odd-major Current releases +# (v23/v25/v27/v29) and even-but-Current drift (v26) that bit mbp13 and the +# primary dev Mac on 2026-05-13. Override the pin by exporting +# DEV_BOOTSTRAP_PINNED_NODE_MAJOR before this snippet runs. +if command -v node >/dev/null 2>&1; then + __dev_bootstrap_node_v="$(node -v 2>/dev/null || true)" + __dev_bootstrap_pin="${DEV_BOOTSTRAP_PINNED_NODE_MAJOR:-22}" + __dev_bootstrap_major="${__dev_bootstrap_node_v#v}" + __dev_bootstrap_major="${__dev_bootstrap_major%%.*}" + if [[ "$__dev_bootstrap_major" =~ ^[0-9]+$ ]] && (( __dev_bootstrap_major != __dev_bootstrap_pin )); then + printf '\033[33mdev-bootstrap: node is %s but pin is v%s (non-LTS drift)\033[0m\n' \ + "$__dev_bootstrap_node_v" "$__dev_bootstrap_pin" >&2 + printf ' recover: brew unlink node && brew link --force --overwrite node@%s\n' \ + "$__dev_bootstrap_pin" >&2 + printf ' or: fnm install --lts && fnm default lts-latest\n' >&2 + fi + unset __dev_bootstrap_node_v __dev_bootstrap_pin __dev_bootstrap_major +fi diff --git a/tests/macos/cli.test.sh b/tests/macos/cli.test.sh new file mode 100644 index 0000000..74a773d --- /dev/null +++ b/tests/macos/cli.test.sh @@ -0,0 +1,75 @@ +# shellcheck shell=bash +# Tests for parse_flags in macos/lib/cli.sh + +# shellcheck disable=SC1091 +source "$REPO_ROOT/macos/lib/cli.sh" + +_make_globals_tmp() { + local tmp + tmp="$(mktemp -t flags-globals.XXXXXX)" + printf 'pnpm\n@openai/codex\n' > "$tmp" + echo "$tmp" +} + +test_parse_flags_defaults_when_no_args() { + parse_flags + assert_equals "0" "$BREW_ONLY" + assert_equals "1" "$USE_FNM" + assert_equals "0" "$FORCE_RELINK" + assert_equals "" "$RESTORE_GLOBALS_FILE" +} + +test_parse_flags_brew_only_sets_mode() { + parse_flags --brew-only + assert_equals "1" "$BREW_ONLY" + assert_equals "0" "$USE_FNM" +} + +test_parse_flags_fnm_keeps_default_mode() { + parse_flags --fnm + assert_equals "0" "$BREW_ONLY" + assert_equals "1" "$USE_FNM" +} + +test_parse_flags_force_relink() { + parse_flags --force-relink + assert_equals "1" "$FORCE_RELINK" + assert_equals "1" "$USE_FNM" +} + +test_parse_flags_brew_only_and_fnm_are_mutex() { + assert_failure parse_flags --brew-only --fnm + assert_failure parse_flags --fnm --brew-only +} + +test_parse_flags_restore_globals_captures_path() { + local f + f="$(_make_globals_tmp)" + parse_flags --restore-globals "$f" + assert_equals "$f" "$RESTORE_GLOBALS_FILE" + rm -f "$f" +} + +test_parse_flags_restore_globals_requires_arg() { + assert_failure parse_flags --restore-globals +} + +test_parse_flags_restore_globals_fails_for_missing_file() { + assert_failure parse_flags --restore-globals "/nonexistent/$$-flags.txt" +} + +test_parse_flags_restore_globals_rejects_directory_path() { + # A directory passes `-e` but not `-f`; the parser must reject it. + assert_failure parse_flags --restore-globals "/tmp" +} + +test_parse_flags_combo_brew_only_and_force_relink() { + parse_flags --brew-only --force-relink + assert_equals "1" "$BREW_ONLY" + assert_equals "0" "$USE_FNM" + assert_equals "1" "$FORCE_RELINK" +} + +test_parse_flags_rejects_unknown_flag() { + assert_failure parse_flags --no-such-flag +} diff --git a/tests/macos/globals-parser.test.sh b/tests/macos/globals-parser.test.sh new file mode 100644 index 0000000..960cdb8 --- /dev/null +++ b/tests/macos/globals-parser.test.sh @@ -0,0 +1,67 @@ +# shellcheck shell=bash +# Tests for parse_globals_file in macos/lib/node-utils.sh + +# shellcheck disable=SC1091 +source "$REPO_ROOT/macos/lib/node-utils.sh" + +_make_globals_fixture() { + local content="$1" + local tmp + tmp="$(mktemp -t globals.XXXXXX)" + printf '%s' "$content" > "$tmp" + echo "$tmp" +} + +test_parse_globals_file_emits_each_package() { + local f + f="$(_make_globals_fixture $'@anthropic-ai/claude-code\n@openai/codex\npnpm\n')" + local got + got="$(parse_globals_file "$f")" + rm -f "$f" + assert_equals $'@anthropic-ai/claude-code\n@openai/codex\npnpm' "$got" +} + +test_parse_globals_file_skips_blank_lines() { + local f + f="$(_make_globals_fixture $'pnpm\n\n@openai/codex\n\n\n')" + local got + got="$(parse_globals_file "$f")" + rm -f "$f" + assert_equals $'pnpm\n@openai/codex' "$got" +} + +test_parse_globals_file_skips_hash_comment_lines() { + local f + f="$(_make_globals_fixture $'# saved on 2026-05-13\npnpm\n# legacy:\n# typescript@4\n')" + local got + got="$(parse_globals_file "$f")" + rm -f "$f" + assert_equals 'pnpm' "$got" +} + +test_parse_globals_file_strips_trailing_hash_comment() { + local f + f="$(_make_globals_fixture $'pnpm # corepack-bypass\n@openai/codex\n')" + local got + got="$(parse_globals_file "$f")" + rm -f "$f" + assert_equals $'pnpm\n@openai/codex' "$got" +} + +test_parse_globals_file_handles_scoped_packages_with_comments() { + # Scoped names (@org/pkg) must survive whole-line and trailing # comments. + local f + f="$(_make_globals_fixture $'# saved 2026-05-13\n@anthropic-ai/claude-code # native installer covers this, skip\n@openai/codex@latest\n@scope/with-version@1.2.3 # pinned\n')" + local got + got="$(parse_globals_file "$f")" + rm -f "$f" + assert_equals $'@anthropic-ai/claude-code\n@openai/codex@latest\n@scope/with-version@1.2.3' "$got" +} + +test_parse_globals_file_returns_nonzero_when_missing() { + assert_failure parse_globals_file "/nonexistent/path-$$-globals.txt" +} + +test_parse_globals_file_returns_nonzero_when_no_arg() { + assert_failure parse_globals_file +} diff --git a/tests/macos/node-utils.test.sh b/tests/macos/node-utils.test.sh new file mode 100644 index 0000000..0936ae8 --- /dev/null +++ b/tests/macos/node-utils.test.sh @@ -0,0 +1,38 @@ +# shellcheck shell=bash +# Tests for macos/lib/node-utils.sh + +# shellcheck disable=SC1091 +source "$REPO_ROOT/macos/lib/node-utils.sh" + +test_is_non_lts_node_version_detects_v25() { + assert_success is_non_lts_node_version "v25.7.0" +} + +test_is_non_lts_node_version_accepts_pinned_v22() { + assert_failure is_non_lts_node_version "v22.11.0" +} + +test_is_non_lts_node_version_detects_even_v26() { + # 2026-05-13 incident: primary Mac was on v26.0.0 (even-major-but-Current). + # Broader rule from the odd-only AC: anything that isn't the pinned LTS warns. + assert_success is_non_lts_node_version "v26.0.0" +} + +test_is_non_lts_node_version_detects_v23() { + assert_success is_non_lts_node_version "v23.0.0" +} + +test_is_non_lts_node_version_quiet_on_malformed_input() { + assert_failure is_non_lts_node_version "garbage" +} + +test_is_non_lts_node_version_quiet_on_empty_input() { + assert_failure is_non_lts_node_version "" +} + +test_is_non_lts_node_version_honours_pin_override() { + # When the LTS pin moves (e.g. v24 becomes Active LTS), the second arg lets + # callers express the new pin without editing the helper. + assert_failure is_non_lts_node_version "v24.0.0" "24" + assert_success is_non_lts_node_version "v22.11.0" "24" +} diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..952ce02 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Minimal bash test runner. +# +# Discovers every tests/**/*.test.sh, sources it, then invokes every function +# named test_*. A test passes when its function returns 0; any non-zero return +# (including the implicit return from `set -e` after a failed assertion) counts +# as failure. Output mirrors the contract of common runners: one line per test, +# tally at the end, exit code = number of failures. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +PASS=0 +FAIL=0 +FAILED=() + +assert_equals() { + # Usage: assert_equals [] + local expected="$1" + local actual="$2" + local message="${3:-values not equal}" + if [[ "$expected" != "$actual" ]]; then + printf ' %s\n expected: %q\n actual: %q\n' "$message" "$expected" "$actual" >&2 + return 1 + fi +} + +assert_success() { + # Usage: assert_success + if ! "$@" >/dev/null 2>&1; then + printf ' expected success, got exit %d running: %s\n' "$?" "$*" >&2 + return 1 + fi +} + +assert_failure() { + # Usage: assert_failure + if "$@" >/dev/null 2>&1; then + printf ' expected non-zero exit, got success running: %s\n' "$*" >&2 + return 1 + fi +} + +shopt -s nullglob globstar +TEST_FILES=( tests/**/*.test.sh ) +shopt -u nullglob globstar + +if [[ ${#TEST_FILES[@]} -eq 0 ]]; then + echo "no tests found under tests/" >&2 + exit 0 +fi + +for f in "${TEST_FILES[@]}"; do + echo "── $f" + # shellcheck disable=SC1090 + source "$f" +done + +mapfile -t TESTS < <(declare -F | awk '/^declare -f test_/{print $3}') + +for t in "${TESTS[@]}"; do + if ( "$t" ); then + printf ' PASS %s\n' "$t" + PASS=$((PASS + 1)) + else + printf ' FAIL %s\n' "$t" + FAIL=$((FAIL + 1)) + FAILED+=("$t") + fi +done + +echo "────" +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +if [[ $FAIL -gt 0 ]]; then + printf 'Failed:\n' + for t in "${FAILED[@]}"; do printf ' - %s\n' "$t"; done +fi +exit "$FAIL" From 590ad7d77da80a2b8599eef5507920cc6caabf74 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Wed, 13 May 2026 15:18:18 -0500 Subject: [PATCH 3/8] test(run): exit on cd failure instead of running tests in wrong cwd Without `set -e`, a failed `cd "$REPO_ROOT"` would silently leave the runner in the caller's working directory and produce confusing "tests/ not found" output. Surface the failure explicitly instead. Per CodeRabbit review on PR #6. --- tests/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/run.sh b/tests/run.sh index 952ce02..13cd99f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -10,7 +10,7 @@ set -uo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" +cd "$REPO_ROOT" || { echo "tests/run.sh: cannot cd to $REPO_ROOT" >&2; exit 1; } PASS=0 FAIL=0 From f7db08cb505c0da0250c19c0292b2f3bbc3559a9 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 23:54:29 +0000 Subject: [PATCH 4/8] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- tests/macos/globals-parser.test.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/macos/globals-parser.test.sh b/tests/macos/globals-parser.test.sh index 960cdb8..987242d 100644 --- a/tests/macos/globals-parser.test.sh +++ b/tests/macos/globals-parser.test.sh @@ -18,7 +18,7 @@ test_parse_globals_file_emits_each_package() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals $'@anthropic-ai/claude-code\n@openai/codex\npnpm' "$got" + assert_equals "$'@anthropic-ai/claude-code\n@openai/codex\npnpm'" "$got" } test_parse_globals_file_skips_blank_lines() { @@ -27,7 +27,7 @@ test_parse_globals_file_skips_blank_lines() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals $'pnpm\n@openai/codex' "$got" + assert_equals "$'pnpm\n@openai/codex'" "$got" } test_parse_globals_file_skips_hash_comment_lines() { @@ -45,7 +45,7 @@ test_parse_globals_file_strips_trailing_hash_comment() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals $'pnpm\n@openai/codex' "$got" + assert_equals "$'pnpm\n@openai/codex'" "$got" } test_parse_globals_file_handles_scoped_packages_with_comments() { @@ -55,7 +55,7 @@ test_parse_globals_file_handles_scoped_packages_with_comments() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals $'@anthropic-ai/claude-code\n@openai/codex@latest\n@scope/with-version@1.2.3' "$got" + assert_equals "$'@anthropic-ai/claude-code\n@openai/codex@latest\n@scope/with-version@1.2.3'" "$got" } test_parse_globals_file_returns_nonzero_when_missing() { From b529483848e5b68e6761caf78fbe9cc9b3103ac0 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Thu, 14 May 2026 09:57:58 -0500 Subject: [PATCH 5/8] Revert "fix: apply CodeRabbit auto-fixes" This reverts commit f7db08cb505c0da0250c19c0292b2f3bbc3559a9. --- tests/macos/globals-parser.test.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/macos/globals-parser.test.sh b/tests/macos/globals-parser.test.sh index 987242d..960cdb8 100644 --- a/tests/macos/globals-parser.test.sh +++ b/tests/macos/globals-parser.test.sh @@ -18,7 +18,7 @@ test_parse_globals_file_emits_each_package() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals "$'@anthropic-ai/claude-code\n@openai/codex\npnpm'" "$got" + assert_equals $'@anthropic-ai/claude-code\n@openai/codex\npnpm' "$got" } test_parse_globals_file_skips_blank_lines() { @@ -27,7 +27,7 @@ test_parse_globals_file_skips_blank_lines() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals "$'pnpm\n@openai/codex'" "$got" + assert_equals $'pnpm\n@openai/codex' "$got" } test_parse_globals_file_skips_hash_comment_lines() { @@ -45,7 +45,7 @@ test_parse_globals_file_strips_trailing_hash_comment() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals "$'pnpm\n@openai/codex'" "$got" + assert_equals $'pnpm\n@openai/codex' "$got" } test_parse_globals_file_handles_scoped_packages_with_comments() { @@ -55,7 +55,7 @@ test_parse_globals_file_handles_scoped_packages_with_comments() { local got got="$(parse_globals_file "$f")" rm -f "$f" - assert_equals "$'@anthropic-ai/claude-code\n@openai/codex@latest\n@scope/with-version@1.2.3'" "$got" + assert_equals $'@anthropic-ai/claude-code\n@openai/codex@latest\n@scope/with-version@1.2.3' "$got" } test_parse_globals_file_returns_nonzero_when_missing() { From c8a05caa999351205d7d4e19417575838009c807 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Thu, 14 May 2026 09:58:38 -0500 Subject: [PATCH 6/8] test(run): fail on zero-test discovery, enforce errexit per test Two CodeRabbit findings: 1. `exit 0` on zero discovered tests masked broken globbing in CI as a green run. Switch to `exit 1` so a misconfigured discovery path is loud, not silent. 2. The per-test subshell did not set `errexit`, so a failing `assert_*` followed by any zero-exit command (e.g., trailing `echo`, `rm -f`) was masked and the test was recorded PASS. Run each test under `set -e` so the first failing assertion exits the subshell. All 25 existing tests still pass under the stricter loop; the change narrows what counts as PASS. --- tests/run.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 13cd99f..e90d2c0 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -49,7 +49,7 @@ shopt -u nullglob globstar if [[ ${#TEST_FILES[@]} -eq 0 ]]; then echo "no tests found under tests/" >&2 - exit 0 + exit 1 fi for f in "${TEST_FILES[@]}"; do @@ -61,7 +61,9 @@ done mapfile -t TESTS < <(declare -F | awk '/^declare -f test_/{print $3}') for t in "${TESTS[@]}"; do - if ( "$t" ); then + # set -e so any failed assert_* in the test body fails the subshell, even + # when later commands in the same test happen to succeed. + if ( set -e; "$t" ); then printf ' PASS %s\n' "$t" PASS=$((PASS + 1)) else From 999de8948c7e448c073b713e25b56f265f6c7886 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Thu, 14 May 2026 10:06:45 -0500 Subject: [PATCH 7/8] refactor: simplify per parallel reuse/quality/efficiency review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse: - bootstrap.ps1 Node section now calls `EnsureFnmActivated` instead of inlining the same `fnm env | iex` line (was duplicated across the Node install, Claude Code migration, Codex install, and pnpm fallback). - macos/bootstrap.sh::remediate_existing_brew_node now calls `is_non_lts_node_version` instead of re-implementing the major-extract logic that already lives in macos/lib/node-utils.sh. - macos/lib/node-utils.sh now owns the canonical `PINNED_NODE_LTS_MAJOR` with an inline "if you bump this, also update X/Y/Z" pointer. - Promoted `make_globals_fixture` into tests/run.sh; cli.test.sh and globals-parser.test.sh both use it instead of near-duplicate locals. Quality: - Dropped redundant `USE_FNM` global — `!BREW_ONLY` is the same signal, mutex check uses local `*_explicit` flags. cli.sh and cli.test.sh updated; one fewer piece of duplicated state. - Inlined the nested `_unlink_brew_node` function (bash has no function scoping; it was leaking to global anyway). - Switched flag comparisons from `[[ "${X:-0}" == "1" ]]` to `(( X ))` arithmetic — the defensive `:-0` is no longer needed now that bootstrap.sh pre-initializes the globals. - macos/zshrc.snippet.sh keeps the major-extract logic inlined (avoids sourcing a lib in every interactive shell startup) with a comment cross-referencing node-utils.sh so the two stay in sync. Efficiency: - restore_npm_globals now snapshots `npm ls -g --depth=0 --parseable` once and matches per-package against an awk-extracted name list with `grep -Fx` (exact-line literal). Removes N+1 `npm list -g` cold-start cost on a long globals file and avoids the `/$pkg` substring false positive (e.g. `foo` vs `foo-utilities`). - Added a RETURN trap to clean up the failure-log tempfile even when the function exits via an interrupt or the bootstrap aborts later. - Dropped a redundant `brew list --formula | grep -qx node` probe — `brew list --versions node` alone signals not-installed via empty output. - preflight_brew_paths now short-circuits when brew is absent (mirrors remediate_existing_brew_node's early return). Tests: 25 passed, 0 failed. --- bootstrap.ps1 | 2 +- macos/bootstrap.sh | 147 ++++++++++++++--------------- macos/lib/cli.sh | 20 ++-- macos/lib/node-utils.sh | 20 ++-- macos/zshrc.snippet.sh | 10 +- tests/macos/cli.test.sh | 16 +--- tests/macos/globals-parser.test.sh | 34 ++----- tests/run.sh | 10 ++ 8 files changed, 119 insertions(+), 140 deletions(-) diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 67dd845..cadd6df 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -211,7 +211,7 @@ if (Get-Command fnm -ErrorAction SilentlyContinue) { # ---------------------------- '@ -fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression +EnsureFnmActivated # Idempotency: fnm install --lts is noisy on re-runs ("Installing Node vX.Y.Z" # immediately followed by "warning: Version already installed"). Skip it if any diff --git a/macos/bootstrap.sh b/macos/bootstrap.sh index 5ac4c38..9febe2a 100755 --- a/macos/bootstrap.sh +++ b/macos/bootstrap.sh @@ -2,7 +2,7 @@ set -euo pipefail # macOS bootstrap entrypoint -# Installs: Xcode CLT, Homebrew, git, curl, fnm or node@22 (per --brew-only), +# Installs: Xcode CLT, Homebrew, git, curl, fnm or node@ (per --brew-only), # uv (Python), bun, Claude Code, Codex CLI. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -11,7 +11,6 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Default flag state before parse_flags runs, so any helper that reads these # globals stays safe under `set -u` even if parse_flags is reordered later. BREW_ONLY=0 -USE_FNM=1 FORCE_RELINK=0 RESTORE_GLOBALS_FILE="" @@ -69,11 +68,11 @@ ensure_snippet_in_zshrc_once() { } >> "$zshrc" } -# Pre-flight ownership repair for Homebrew-touched paths that commonly cause -# `compinit` prompts and `brew install` failures (rb_sysopen permission denied) -# after ad-hoc setup. Runs read-only probes first; if any path is broken, -# requests sudo once up front, then repairs only the broken paths. +# Repair ownership of Homebrew-touched paths that cause `compinit` prompts +# and `brew install` to fail with `rb_sysopen permission denied` after ad-hoc +# setup. Sudo is requested once up front, only when something is broken. preflight_brew_paths() { + command -v brew >/dev/null 2>&1 || return 0 local paths=( /usr/local/share/zsh /usr/local/share/zsh/site-functions @@ -109,69 +108,75 @@ preflight_brew_paths() { fi } -# Detect a pre-existing `brew install node` (Current-line node formula). If -# present and non-matching the pin, either prompt to unlink (interactive) or -# unlink unconditionally (--force-relink) so it doesn't shadow fnm/node@22. +# Detect a pre-existing `brew install node` on a non-pinned major and either +# prompt to unlink (interactive) or unlink unconditionally (--force-relink). +# Empty `brew list --versions node` output means not installed. remediate_existing_brew_node() { - if ! command -v brew >/dev/null 2>&1; then - return 0 - fi - if ! brew list --formula 2>/dev/null | grep -qx 'node'; then - return 0 - fi + command -v brew >/dev/null 2>&1 || return 0 local installed installed="$(brew list --versions node 2>/dev/null | awk '{print $2}')" - if [[ -z "$installed" ]]; then - return 0 - fi - local major="${installed%%.*}" - if [[ "$major" == "$PINNED_NODE_LTS_MAJOR" ]]; then + [[ -n "$installed" ]] || return 0 + if ! is_non_lts_node_version "v$installed" "$PINNED_NODE_LTS_MAJOR"; then return 0 fi echo "Detected pre-existing brew Node v$installed (pin is v$PINNED_NODE_LTS_MAJOR)." >&2 - _unlink_brew_node() { - if ! brew unlink node >/dev/null; then - echo " brew unlink node failed. Resolve manually (brew lock contention? partial install?) and re-run." >&2 + local proceed=0 + if (( FORCE_RELINK )); then + echo " --force-relink set: running brew unlink node..." >&2 + proceed=1 + else + local target + target="$( (( BREW_ONLY )) && echo "node@${PINNED_NODE_LTS_MAJOR}" || echo "fnm-managed Node" )" + echo " This will shadow $target." >&2 + printf " Run 'brew unlink node' now? [y/N, 60s timeout] " >&2 + local ans="" + if ! read -r -t 60 ans; then + echo >&2 + echo " Prompt timed out. Re-run with --force-relink or unlink manually." >&2 exit 1 fi - } - if [[ "${FORCE_RELINK:-0}" == "1" ]]; then - echo " --force-relink set: running brew unlink node..." >&2 - _unlink_brew_node - return 0 + case "$ans" in + y|Y|yes) proceed=1 ;; + *) + echo "Aborting. Re-run with --force-relink or unlink manually." >&2 + exit 1 + ;; + esac fi - echo " This will shadow $( [[ "${BREW_ONLY:-0}" == "1" ]] && echo "node@${PINNED_NODE_LTS_MAJOR}" || echo "fnm-managed Node" )." >&2 - printf " Run 'brew unlink node' now? [y/N, 60s timeout] " >&2 - local ans="" - if ! read -r -t 60 ans; then - echo >&2 - echo " Prompt timed out. Re-run with --force-relink or unlink manually." >&2 + if (( proceed )) && ! brew unlink node >/dev/null; then + echo " brew unlink node failed. Resolve manually (brew lock contention? partial install?) and re-run." >&2 exit 1 fi - case "$ans" in - y|Y|yes) - _unlink_brew_node - ;; - *) - echo "Aborting. Re-run with --force-relink or unlink manually." >&2 - exit 1 - ;; - esac } -# Apply a globals-restore list once Node is on PATH. Failures are reported but -# do not abort the bootstrap; each failing install's stderr is preserved so the -# user can diagnose registry / proxy / permission issues from the summary. +# Apply a globals-restore list once Node is on PATH. Failures captured to a +# tempfile (cleaned up via RETURN trap on any exit) and dumped in the summary +# so registry / proxy / permission errors are diagnosable. restore_npm_globals() { local file="$1" [[ -f "$file" ]] || return 0 - local installed=() skipped=() failed=() + local failure_log failure_log="$(mktemp -t globals-restore.XXXXXX.log)" + # shellcheck disable=SC2064 + trap "rm -f '$failure_log'" RETURN + + # One npm fork to snapshot installed globals (then exact-line match per + # package) avoids N+1 cold Node starts on a long globals file. `--parseable` + # gives absolute paths; awk strips to bare names so `foo` doesn't match + # `foo-utilities` and `@scope/foo` works unchanged. + local installed_globals + installed_globals="$( + npm ls -g --depth=0 --parseable 2>/dev/null \ + | awk -F'/node_modules/' 'NF>1 { print $NF }' \ + | sort -u + )" + + local installed=() skipped=() failed=() local pkg while IFS= read -r pkg; do [[ -n "$pkg" ]] || continue - if npm list -g --depth=0 "$pkg" >/dev/null 2>&1; then + if grep -qFx "$pkg" <<<"$installed_globals"; then skipped+=("$pkg") continue fi @@ -182,18 +187,19 @@ restore_npm_globals() { printf -- '--- %s failed ---\n' "$pkg" >> "$failure_log" fi done < <(parse_globals_file "$file") - echo - echo "Globals restore summary:" - echo " installed: ${#installed[@]} (${installed[*]:-none})" - echo " skipped: ${#skipped[@]} (${skipped[*]:-none})" - echo " failed: ${#failed[@]} (${failed[*]:-none})" - if [[ ${#failed[@]} -gt 0 ]]; then + + { echo - echo " Failure details ($failure_log):" - sed 's/^/ /' "$failure_log" >&2 - else - rm -f "$failure_log" - fi + echo "Globals restore summary:" + echo " installed: ${#installed[@]} (${installed[*]:-none})" + echo " skipped: ${#skipped[@]} (${skipped[*]:-none})" + echo " failed: ${#failed[@]} (${failed[*]:-none})" + if [[ ${#failed[@]} -gt 0 ]]; then + echo + echo " Failure details:" + sed 's/^/ /' "$failure_log" + fi + } >&2 } log "Preflight: Xcode Command Line Tools" @@ -236,7 +242,9 @@ log "Terminal UX (lazygit, yazi)" brew list lazygit >/dev/null 2>&1 || brew install lazygit brew list yazi >/dev/null 2>&1 || brew install yazi -if [[ "${BREW_ONLY:-0}" == "1" ]]; then +SNIPPET="$REPO_ROOT/macos/zshrc.snippet.sh" + +if (( BREW_ONLY )); then log "Node via brew node@${PINNED_NODE_LTS_MAJOR} (Plan B, --brew-only)" brew list "node@${PINNED_NODE_LTS_MAJOR}" >/dev/null 2>&1 || brew install "node@${PINNED_NODE_LTS_MAJOR}" # Only re-link when something other than the pinned keg owns the current node binary, @@ -245,33 +253,24 @@ if [[ "${BREW_ONLY:-0}" == "1" ]]; then if [[ "$active_node" != *"node@${PINNED_NODE_LTS_MAJOR}/"* ]]; then brew link --force --overwrite "node@${PINNED_NODE_LTS_MAJOR}" fi - SNIPPET="$REPO_ROOT/macos/zshrc.snippet.sh" ensure_snippet_in_zshrc_once "$SNIPPET" - require_cmd node - require_cmd npm - node -v - npm -v else log "Node via fnm" brew list fnm >/dev/null 2>&1 || brew install fnm - - SNIPPET="$REPO_ROOT/macos/zshrc.snippet.sh" require_cmd fnm ensure_snippet_in_zshrc_once "$SNIPPET" - - # shellcheck disable=SC2046 if fnm env --use-on-cd >/dev/null 2>&1; then eval "$(fnm env --use-on-cd)" fi - fnm install --lts fnm default lts-latest - require_cmd node - require_cmd npm - node -v - npm -v fi +require_cmd node +require_cmd npm +node -v +npm -v + log "Python via uv" if ! command -v uv >/dev/null 2>&1; then curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/macos/lib/cli.sh b/macos/lib/cli.sh index 3b4a65c..5203a5f 100644 --- a/macos/lib/cli.sh +++ b/macos/lib/cli.sh @@ -1,16 +1,12 @@ # shellcheck shell=bash -# CLI flag parsing for macos/bootstrap.sh. -# parse_flags sets the following globals (all initialized to defaults on every call): -# BREW_ONLY "0" / "1" — install node@22 via brew, skip fnm path -# USE_FNM "0" / "1" — install LTS via fnm (default mode) -# FORCE_RELINK "0" / "1" — non-interactive remediation of pre-existing brew Node -# RESTORE_GLOBALS_FILE path — file of npm package names to reinstall (or empty) -# Returns 0 on success, 1 on usage error (unknown flag, missing arg, mutex violation, -# or --restore-globals pointing at a missing file). +# parse_flags sets these globals (initialized on every call): +# BREW_ONLY 0 / 1 — install node@ via brew, skip fnm path +# FORCE_RELINK 0 / 1 — non-interactive remediation of pre-existing brew Node +# RESTORE_GLOBALS_FILE path — file of npm package names to reinstall (or empty) +# Returns 0 on success, 1 on usage error, 2 on --help. parse_flags() { BREW_ONLY=0 - USE_FNM=1 FORCE_RELINK=0 RESTORE_GLOBALS_FILE="" @@ -21,12 +17,10 @@ parse_flags() { case "$1" in --brew-only) BREW_ONLY=1 - USE_FNM=0 brew_only_explicit=1 ;; --fnm) BREW_ONLY=0 - USE_FNM=1 fnm_explicit=1 ;; --force-relink) @@ -48,7 +42,7 @@ parse_flags() { cat <<'USAGE' Usage: macos/bootstrap.sh [options] - --brew-only Install node@22 via Homebrew; skip fnm. + --brew-only Install node@ via Homebrew; skip fnm. Mutually exclusive with --fnm. --fnm Install LTS Node via fnm (default). Mutually exclusive with --brew-only. @@ -71,7 +65,7 @@ USAGE shift done - if [[ "$brew_only_explicit" == "1" && "$fnm_explicit" == "1" ]]; then + if (( brew_only_explicit && fnm_explicit )); then echo "--brew-only and --fnm are mutually exclusive — pick one" >&2 return 1 fi diff --git a/macos/lib/node-utils.sh b/macos/lib/node-utils.sh index fd57ddb..e0dc3c9 100644 --- a/macos/lib/node-utils.sh +++ b/macos/lib/node-utils.sh @@ -1,14 +1,16 @@ # shellcheck shell=bash -# Pure helpers used by macos/bootstrap.sh and macos/zshrc.snippet.sh. -# No side effects; safe to source from interactive shells. +# Pure helpers for macos/bootstrap.sh. +# Canonical pin for the LTS Node major. If you bump this, also update: +# - macos/zshrc.snippet.sh (DEV_BOOTSTRAP_PINNED_NODE_MAJOR default) +# - README.md "Migrating a Mac that was already set up ad-hoc" recipe +# - macos/bootstrap.sh log line referencing the pin PINNED_NODE_LTS_MAJOR="${PINNED_NODE_LTS_MAJOR:-22}" is_non_lts_node_version() { - # Returns 0 when the given Node version's major does not match the pinned - # LTS major. Returns 1 otherwise, including for malformed or empty input. - # Accepts "vX.Y.Z" or "X.Y.Z". Optional second arg overrides the pinned - # major (defaults to $PINNED_NODE_LTS_MAJOR, default 22). + # Returns 0 when the input version's major != pin. Accepts "vX.Y.Z" or "X.Y.Z". + # Returns 1 for the pinned major, empty input, or malformed input. + # Optional second arg overrides the pin. local raw="${1:-}" raw="${raw#v}" local major="${raw%%.*}" @@ -19,10 +21,8 @@ is_non_lts_node_version() { } parse_globals_file() { - # Reads a globals-list file (one npm package name per line) and emits each - # name on stdout, skipping blank lines and stripping `#` comments (both - # whole-line comments and trailing comments). Returns 1 if the file is - # missing or no path was given. + # Emits each npm package name on stdout, skipping blank lines and stripping + # `#` comments (whole-line and trailing). Returns 1 if the file is missing. local file="${1:-}" [[ -n "$file" && -f "$file" ]] || return 1 awk ' diff --git a/macos/zshrc.snippet.sh b/macos/zshrc.snippet.sh index 222a940..0b053a5 100644 --- a/macos/zshrc.snippet.sh +++ b/macos/zshrc.snippet.sh @@ -8,11 +8,13 @@ if command -v fnm >/dev/null 2>&1; then eval "$(fnm env --use-on-cd)" fi -# Non-LTS Node guard. Warns on shell startup when `node -v` is not on the -# pinned LTS major (default 22). Catches both odd-major Current releases +# Non-LTS Node drift warning. Catches both odd-major Current releases # (v23/v25/v27/v29) and even-but-Current drift (v26) that bit mbp13 and the -# primary dev Mac on 2026-05-13. Override the pin by exporting -# DEV_BOOTSTRAP_PINNED_NODE_MAJOR before this snippet runs. +# primary dev Mac on 2026-05-13. The logic is inlined (not sourced from +# macos/lib/node-utils.sh::is_non_lts_node_version) to avoid an extra file +# read in every interactive shell startup — but the two MUST stay in sync. +# If you change one, change the other. Override the pin by exporting +# DEV_BOOTSTRAP_PINNED_NODE_MAJOR before this snippet runs (default 22). if command -v node >/dev/null 2>&1; then __dev_bootstrap_node_v="$(node -v 2>/dev/null || true)" __dev_bootstrap_pin="${DEV_BOOTSTRAP_PINNED_NODE_MAJOR:-22}" diff --git a/tests/macos/cli.test.sh b/tests/macos/cli.test.sh index 74a773d..bb734bc 100644 --- a/tests/macos/cli.test.sh +++ b/tests/macos/cli.test.sh @@ -4,17 +4,9 @@ # shellcheck disable=SC1091 source "$REPO_ROOT/macos/lib/cli.sh" -_make_globals_tmp() { - local tmp - tmp="$(mktemp -t flags-globals.XXXXXX)" - printf 'pnpm\n@openai/codex\n' > "$tmp" - echo "$tmp" -} - test_parse_flags_defaults_when_no_args() { parse_flags assert_equals "0" "$BREW_ONLY" - assert_equals "1" "$USE_FNM" assert_equals "0" "$FORCE_RELINK" assert_equals "" "$RESTORE_GLOBALS_FILE" } @@ -22,19 +14,17 @@ test_parse_flags_defaults_when_no_args() { test_parse_flags_brew_only_sets_mode() { parse_flags --brew-only assert_equals "1" "$BREW_ONLY" - assert_equals "0" "$USE_FNM" } test_parse_flags_fnm_keeps_default_mode() { parse_flags --fnm assert_equals "0" "$BREW_ONLY" - assert_equals "1" "$USE_FNM" } test_parse_flags_force_relink() { parse_flags --force-relink assert_equals "1" "$FORCE_RELINK" - assert_equals "1" "$USE_FNM" + assert_equals "0" "$BREW_ONLY" } test_parse_flags_brew_only_and_fnm_are_mutex() { @@ -44,7 +34,7 @@ test_parse_flags_brew_only_and_fnm_are_mutex() { test_parse_flags_restore_globals_captures_path() { local f - f="$(_make_globals_tmp)" + f="$(make_globals_fixture 'pnpm\n@openai/codex\n')" parse_flags --restore-globals "$f" assert_equals "$f" "$RESTORE_GLOBALS_FILE" rm -f "$f" @@ -59,14 +49,12 @@ test_parse_flags_restore_globals_fails_for_missing_file() { } test_parse_flags_restore_globals_rejects_directory_path() { - # A directory passes `-e` but not `-f`; the parser must reject it. assert_failure parse_flags --restore-globals "/tmp" } test_parse_flags_combo_brew_only_and_force_relink() { parse_flags --brew-only --force-relink assert_equals "1" "$BREW_ONLY" - assert_equals "0" "$USE_FNM" assert_equals "1" "$FORCE_RELINK" } diff --git a/tests/macos/globals-parser.test.sh b/tests/macos/globals-parser.test.sh index 960cdb8..0395d9b 100644 --- a/tests/macos/globals-parser.test.sh +++ b/tests/macos/globals-parser.test.sh @@ -4,55 +4,41 @@ # shellcheck disable=SC1091 source "$REPO_ROOT/macos/lib/node-utils.sh" -_make_globals_fixture() { - local content="$1" - local tmp - tmp="$(mktemp -t globals.XXXXXX)" - printf '%s' "$content" > "$tmp" - echo "$tmp" -} - test_parse_globals_file_emits_each_package() { - local f - f="$(_make_globals_fixture $'@anthropic-ai/claude-code\n@openai/codex\npnpm\n')" - local got + local f got + f="$(make_globals_fixture '@anthropic-ai/claude-code\n@openai/codex\npnpm\n')" got="$(parse_globals_file "$f")" rm -f "$f" assert_equals $'@anthropic-ai/claude-code\n@openai/codex\npnpm' "$got" } test_parse_globals_file_skips_blank_lines() { - local f - f="$(_make_globals_fixture $'pnpm\n\n@openai/codex\n\n\n')" - local got + local f got + f="$(make_globals_fixture 'pnpm\n\n@openai/codex\n\n\n')" got="$(parse_globals_file "$f")" rm -f "$f" assert_equals $'pnpm\n@openai/codex' "$got" } test_parse_globals_file_skips_hash_comment_lines() { - local f - f="$(_make_globals_fixture $'# saved on 2026-05-13\npnpm\n# legacy:\n# typescript@4\n')" - local got + local f got + f="$(make_globals_fixture '# saved on 2026-05-13\npnpm\n# legacy:\n# typescript@4\n')" got="$(parse_globals_file "$f")" rm -f "$f" assert_equals 'pnpm' "$got" } test_parse_globals_file_strips_trailing_hash_comment() { - local f - f="$(_make_globals_fixture $'pnpm # corepack-bypass\n@openai/codex\n')" - local got + local f got + f="$(make_globals_fixture 'pnpm # corepack-bypass\n@openai/codex\n')" got="$(parse_globals_file "$f")" rm -f "$f" assert_equals $'pnpm\n@openai/codex' "$got" } test_parse_globals_file_handles_scoped_packages_with_comments() { - # Scoped names (@org/pkg) must survive whole-line and trailing # comments. - local f - f="$(_make_globals_fixture $'# saved 2026-05-13\n@anthropic-ai/claude-code # native installer covers this, skip\n@openai/codex@latest\n@scope/with-version@1.2.3 # pinned\n')" - local got + local f got + f="$(make_globals_fixture '# saved 2026-05-13\n@anthropic-ai/claude-code # native installer covers this, skip\n@openai/codex@latest\n@scope/with-version@1.2.3 # pinned\n')" got="$(parse_globals_file "$f")" rm -f "$f" assert_equals $'@anthropic-ai/claude-code\n@openai/codex@latest\n@scope/with-version@1.2.3' "$got" diff --git a/tests/run.sh b/tests/run.sh index e90d2c0..801ee10 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -43,6 +43,16 @@ assert_failure() { fi } +make_globals_fixture() { + # Writes (interpret \n, \t, etc.) to a tempfile and prints the path. + local content="$1" + local tmp + tmp="$(mktemp -t globals.XXXXXX)" + # shellcheck disable=SC2059 + printf "$content" > "$tmp" + echo "$tmp" +} + shopt -s nullglob globstar TEST_FILES=( tests/**/*.test.sh ) shopt -u nullglob globstar From 53c68372a521cdeb68039b4b78fedd275fb0b477 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Thu, 14 May 2026 10:07:24 -0500 Subject: [PATCH 8/8] test(run): capture command exit code before conditional in assert_success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$?` inside the `if !` body reflects the `if` test's exit (always 0), not the underlying command's. The error message printed "exit 0" for every failure. Save `$?` immediately after the call instead. Cosmetic only — no test passes or fails differently. --- tests/run.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 801ee10..10b00fd 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -29,8 +29,11 @@ assert_equals() { assert_success() { # Usage: assert_success - if ! "$@" >/dev/null 2>&1; then - printf ' expected success, got exit %d running: %s\n' "$?" "$*" >&2 + local rc + "$@" >/dev/null 2>&1 + rc=$? + if [[ $rc -ne 0 ]]; then + printf ' expected success, got exit %d running: %s\n' "$rc" "$*" >&2 return 1 fi }