Repository navigation
Description
What to build
macos/bootstrap.sh already installs Node via fnm install --lts, which is the right design. But two real Macs (mbp13 and the primary dev Mac) were both running odd-line Node (v25.7.0 and v26.0.0 respectively) on 2026-05-13, both via ad-hoc brew install node that predated this script, and both crashed Claude Code and qmd (via better-sqlite3 ABI mismatch). The fix took ~45 min across the two boxes.
Harden the macOS bootstrap so it's safe to run on a Mac that's already been touched ad-hoc, and so it produces a system where Node version cannot silently drift to a non-LTS Current-line release on the next brew upgrade. This is a defensive/idempotency pass, not a redesign.
Acceptance criteria
- Script detects an existing
brewinstallation of thenodeformula (any Current-line version) and eitherbrew unlink nodes it OR warns and prompts before continuing — pre-existing brew Node must not shadowfnm - Script offers a Plan B: when the user wants a stable, brew-only setup (no fnm shell hook needed), install
node@22andbrew link --force --overwrite node@22directly — single-flag toggle, documented in README - Script pre-flights and fixes ownership of
/usr/local/share/zsh,/usr/local/share/zsh/site-functions,/usr/local/var/homebrew,/usr/local/Cellar,/opt/homebrew/share/zsh,/opt/homebrew/var/homebrew(Intel + Apple Silicon paths) socompinitdoesn't prompt andbrew installdoesn't fail withPermission denied @ rb_sysopen— both happened on mbp13 on 2026-05-13 -
zshrc.snippet.shappends a guard that warns on shell startup ifnode -vis on an odd-line release (v23/v25/v27/v29), and prints the recovery command inline - Script supports a
--restore-globals <file>flag that reads a list of npm package names (one per line, as produced bynpm ls -g --depth=0 --json | jq -r '.dependencies | keys[]') and reinstalls them on the now-pinned Node, so machine-to-machine moves and Node-version flips don't lose globals silently - README has a "Migrating a Mac that was already set up ad-hoc" section showing the exact commands run on 2026-05-13: save globals list →
brew install node@22→brew unlink node && brew link --force --overwrite node@22→ reinstall globals → uninstall old keg - Script is idempotent: rerunning on an already-bootstrapped Mac produces zero changes and exit 0
- Run end-to-end on
mbp13and the primary Mac to confirm both reach a green state; close existingnpmnot found during Claude Code / Codex install — fnm not activated in piped execution scope #3 if the fnm-in-pipe fix is covered by the same patch, otherwise reference it from this issue
Blocked by
None — can start immediately. Touches existing issue #3 (npm not found during Claude Code / Codex install — fnm not activated in piped execution scope); fold its fix in if the activation order is part of this hardening pass.
Activity
linear commentedon May 13, 2026
HOM-57
AojdevStudio commentedon May 13, 2026
Agent Brief
Category: enhancement
Summary: Harden
macos/bootstrap.shso a Mac that was previously touched ad-hoc (especially pre-existingbrew install node) reaches a green state on first run, and so Node version cannot silently drift to a non-LTS Current-line release later. Bundled with the Windows fix in #3 into a single PR — proposed PR title: bootstrap: harden fnm/Node activation across macOS + Windows.Current behavior:
macos/bootstrap.shinstalls Node viafnm install --lts, which is the correct default. In practice, on Macs that already hadbrew install nodefrom prior ad-hoc setup, the brew-installed Current-line Node (v25 / v26 on 2026-05-13) shadows the fnm-managed LTS on PATH. This breaks Claude Code andqmdviabetter-sqlite3ABI mismatch and required ~45 min of manual recovery per machine acrossmbp13and the primary dev Mac. The script also does not pre-flight common Homebrew permission issues on/usr/local/share/zsh*,/usr/local/var/homebrew,/usr/local/Cellar, and the/opt/homebrew/*equivalents, which causecompinitprompts andbrew installfailures withPermission denied @ rb_sysopen. There is no machinery for restoring global npm packages when migrating between Macs or flipping Node versions.Desired behavior:
The script becomes idempotent and self-healing on a Mac that's already been touched ad-hoc. Pre-existing brew Node is detected and resolved before the fnm path runs. A "Plan B"
--brew-onlyflag is offered for users who want a stable, brew-onlynode@22setup without any fnm shell hook. Homebrew-owned paths that commonly breakcompinitandbrew installare pre-flighted and repaired with the user's explicit sudo grant, gathered in one prompt up front. A guard in the appended zsh snippet warns on shell startup whennode -vis on an odd-line release. A--restore-globals <file>flag re-installs a saved list of npm globals on the now-pinned Node. The README gains a "Migrating a Mac that was already set up ad-hoc" section with the exact recovery sequence used on 2026-05-13.Key interfaces:
macos/bootstrap.sh) — new CLI flags:--brew-only,--force-relink,--restore-globals <file>. Default mode (no flags) preserves today's fnm-based behavior plus the new safety probes.--brew-onlyand the default fnm path are mutually exclusive: invoking both errors early with a message telling the user to pick one (exit 1).--force-relinkmakes the pre-existing-brew-Node remediation non-interactive (autobrew unlink node) for CI / scripted reruns. Without it, the script warns and prompts.chown $(whoami):staffruns only on paths that failtest -w. Sudo is requested once up front in a single prompt rather than per-path. Paths that don't exist on the current arch (Intel vs Apple Silicon) are skipped silently.~/.zshrc— adds a guard that runs once per interactive shell startup, callsnode -vonly ifnodeis on PATH, and prints a warning plus inline recovery command when the major version is odd (v23 / v25 / v27 / v29 …).--restore-globals <file>— file format is one npm package name per line, exactly as produced bynpm ls -g --depth=0 --json | jq -r '.dependencies | keys[]'. The script installs each at@latest(no version pinning). Failures are skipped and reported at the end; the script does not abort on a single failed install.brew install node@22→brew unlink node && brew link --force --overwrite node@22→ reinstall globals → uninstall the old keg.Acceptance criteria:
nodeformula installed (brew list --formulaincludesnode), the script detects it before invokingfnmand either warns + prompts (default) orbrew unlink nodes automatically with--force-relink. It does not silently proceed into a broken state where brew Node shadows fnm Node.--brew-onlyinstallsnode@22, runsbrew link --force --overwrite node@22, and skips the fnm path entirely. Passing both--brew-onlyand any fnm-implying option errors with a clear message and exits 1.brew install, the script tests writability of/usr/local/share/zsh,/usr/local/share/zsh/site-functions,/usr/local/var/homebrew,/usr/local/Cellar,/opt/homebrew/share/zsh, and/opt/homebrew/var/homebrew. Paths that failtest -ware repaired withsudo chown $(whoami):staffafter a single up-front sudo prompt. Paths that don't exist on the current arch are skipped silently.~/.zshrcincludes a guard that runs once per shell startup, checksnode -vonly whennodeis on PATH, and prints a warning + recovery command when the major version is odd (v23 / v25 / v27 / v29 …).--restore-globals <file>reads the file, runsnpm install -g <pkg>@latestfor each package, skips failures, and prints a summary of installed / skipped / failed packages at the end. The script exits 0 when at least one install succeeds and there are no other script errors.brew install, nochown, no file writes. Read-only probes (brew list,test -w,node -v) are allowed. Exit 0.mbp13and the primary dev Mac reaches a green state. This is a maintainer-verified merge gate: the agent may complete all other criteria, but the PR holds until the maintainer signs off on the two real-Mac runs.Out of scope:
linux/bootstrap.sh) and WSL bootstrappers — separate platforms, separate hardening passes.bootstrap.ps1) — covered bynpmnot found during Claude Code / Codex install — fnm not activated in piped execution scope #3 in the same PR.node@22as the LTS pin.--restore-globalsfrom a cached globals list — restoring is opt-in via the explicit flag.AojdevStudio commentedon May 13, 2026
Implementation note — AC #4 broadened
While implementing in #6, AC #4 ("warns on shell startup if
node -vis on an odd-line release (v23/v25/v27/v29)") was broadened to a non-LTS check: warn whennode -v's major ≠ the pinned LTS major (default 22).Why: the 2026-05-13 incident on the primary dev Mac was v26.0.0 — even-major-but-Current. An odd-only guard would have slipped past that one. The broader rule catches both odd-line drift (v23/v25/v27/v29) and even-but-Current drift (v26).
How to override: export
DEV_BOOTSTRAP_PINNED_NODE_MAJORbefore the snippet runs to change the pin (e.g. when v24 becomes Active LTS).Code lives in
macos/zshrc.snippet.shandmacos/lib/node-utils.sh::is_non_lts_node_version. All other ACs in this issue are implemented to spec.