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.
+
diff --git a/bootstrap.ps1 b/bootstrap.ps1
index 677d513..cadd6df 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 }
@@ -201,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
@@ -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) {
diff --git a/macos/bootstrap.sh b/macos/bootstrap.sh
index 5ca78fe..9febe2a 100755
--- a/macos/bootstrap.sh
+++ b/macos/bootstrap.sh
@@ -2,7 +2,29 @@
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@ (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
+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 +41,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 +68,145 @@ ensure_snippet_in_zshrc_once() {
} >> "$zshrc"
}
+# 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
+ /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` 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() {
+ command -v brew >/dev/null 2>&1 || return 0
+ local installed
+ installed="$(brew list --versions node 2>/dev/null | awk '{print $2}')"
+ [[ -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
+ 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
+ case "$ans" in
+ y|Y|yes) proceed=1 ;;
+ *)
+ echo "Aborting. Re-run with --force-relink or unlink manually." >&2
+ exit 1
+ ;;
+ esac
+ fi
+ 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
+}
+
+# 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 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 grep -qFx "$pkg" <<<"$installed_globals"; 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:"
+ sed 's/^/ /' "$failure_log"
+ fi
+ } >&2
+}
+
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 +218,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,40 +225,47 @@ 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
-
-# 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)"
+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,
+ # 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
+ ensure_snippet_in_zshrc_once "$SNIPPET"
+else
+ log "Node via fnm"
+ brew list fnm >/dev/null 2>&1 || brew install fnm
+ require_cmd fnm
+ ensure_snippet_in_zshrc_once "$SNIPPET"
+ 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
fi
-fnm install --lts
-fnm default lts-latest
require_cmd node
require_cmd npm
node -v
@@ -115,9 +275,6 @@ 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 +291,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 +300,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..5203a5f
--- /dev/null
+++ b/macos/lib/cli.sh
@@ -0,0 +1,74 @@
+# shellcheck shell=bash
+# 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
+ 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
+ brew_only_explicit=1
+ ;;
+ --fnm)
+ BREW_ONLY=0
+ 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@ 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 && fnm_explicit )); 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..e0dc3c9
--- /dev/null
+++ b/macos/lib/node-utils.sh
@@ -0,0 +1,36 @@
+# shellcheck shell=bash
+# 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 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%%.*}"
+ local pin="${2:-$PINNED_NODE_LTS_MAJOR}"
+ [[ "$major" =~ ^[0-9]+$ ]] || return 1
+ [[ "$pin" =~ ^[0-9]+$ ]] || return 1
+ (( major != pin ))
+}
+
+parse_globals_file() {
+ # 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 '
+ {
+ 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..0b053a5 100644
--- a/macos/zshrc.snippet.sh
+++ b/macos/zshrc.snippet.sh
@@ -7,3 +7,25 @@ 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 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. 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}"
+ __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..bb734bc
--- /dev/null
+++ b/tests/macos/cli.test.sh
@@ -0,0 +1,63 @@
+# shellcheck shell=bash
+# Tests for parse_flags in macos/lib/cli.sh
+
+# shellcheck disable=SC1091
+source "$REPO_ROOT/macos/lib/cli.sh"
+
+test_parse_flags_defaults_when_no_args() {
+ parse_flags
+ assert_equals "0" "$BREW_ONLY"
+ 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"
+}
+
+test_parse_flags_fnm_keeps_default_mode() {
+ parse_flags --fnm
+ assert_equals "0" "$BREW_ONLY"
+}
+
+test_parse_flags_force_relink() {
+ parse_flags --force-relink
+ assert_equals "1" "$FORCE_RELINK"
+ assert_equals "0" "$BREW_ONLY"
+}
+
+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_fixture 'pnpm\n@openai/codex\n')"
+ 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() {
+ 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 "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..0395d9b
--- /dev/null
+++ b/tests/macos/globals-parser.test.sh
@@ -0,0 +1,53 @@
+# 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"
+
+test_parse_globals_file_emits_each_package() {
+ 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 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 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 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() {
+ 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"
+}
+
+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..10b00fd
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,95 @@
+#!/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" || { echo "tests/run.sh: cannot cd to $REPO_ROOT" >&2; exit 1; }
+
+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
+ 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
+}
+
+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
+}
+
+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
+
+if [[ ${#TEST_FILES[@]} -eq 0 ]]; then
+ echo "no tests found under tests/" >&2
+ exit 1
+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
+ # 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
+ 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"