Skip to content

macos/bootstrap.sh: harden against pre-existing brew Node + apply lessons from 2026-05-13 incidents #5

Closed

Description

@AojdevStudio

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 brew installation of the node formula (any Current-line version) and either brew unlink nodes it OR warns and prompts before continuing — pre-existing brew Node must not shadow fnm
  • Script offers a Plan B: when the user wants a stable, brew-only setup (no fnm shell hook needed), install node@22 and brew link --force --overwrite node@22 directly — 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) so compinit doesn't prompt and brew install doesn't fail with Permission denied @ rb_sysopen — both happened on mbp13 on 2026-05-13
  • zshrc.snippet.sh appends a guard that warns on shell startup if node -v is 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 by npm 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 mbp13 and the primary Mac to confirm both reach a green state; close existing npm not 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

  1. linear commented on May 13, 2026

    @linear
  2. AojdevStudio commented on May 13, 2026

    @AojdevStudio
    OwnerAuthor

    This was generated by AI during triage.

    Agent Brief

    Category: enhancement
    Summary: Harden macos/bootstrap.sh so a Mac that was previously touched ad-hoc (especially pre-existing brew 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.sh installs Node via fnm install --lts, which is the correct default. In practice, on Macs that already had brew install node from 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 and qmd via better-sqlite3 ABI mismatch and required ~45 min of manual recovery per machine across mbp13 and 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 cause compinit prompts and brew install failures with Permission 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-only flag is offered for users who want a stable, brew-only node@22 setup without any fnm shell hook. Homebrew-owned paths that commonly break compinit and brew install are 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 when node -v is 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:

    • The macOS bootstrapper (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-only and the default fnm path are mutually exclusive: invoking both errors early with a message telling the user to pick one (exit 1).
    • --force-relink makes the pre-existing-brew-Node remediation non-interactive (auto brew unlink node) for CI / scripted reruns. Without it, the script warns and prompts.
    • Ownership repair: chown $(whoami):staff runs only on paths that fail test -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.
    • Shell snippet appended to ~/.zshrc — adds a guard that runs once per interactive shell startup, calls node -v only if node is 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 by npm 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.
    • README — a new "Migrating a Mac that was already set up ad-hoc" section walking through: save globals list → brew install node@22 → brew unlink node && brew link --force --overwrite node@22 → reinstall globals → uninstall the old keg.

    Acceptance criteria:

    • On a Mac with the Current-line node formula installed (brew list --formula includes node), the script detects it before invoking fnm and either warns + prompts (default) or brew unlink nodes automatically with --force-relink. It does not silently proceed into a broken state where brew Node shadows fnm Node.
    • --brew-only installs node@22, runs brew link --force --overwrite node@22, and skips the fnm path entirely. Passing both --brew-only and any fnm-implying option errors with a clear message and exits 1.
    • Before any 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 fail test -w are repaired with sudo chown $(whoami):staff after a single up-front sudo prompt. Paths that don't exist on the current arch are skipped silently.
    • The shell snippet appended to ~/.zshrc includes a guard that runs once per shell startup, checks node -v only when node is on PATH, and prints a warning + recovery command when the major version is odd (v23 / v25 / v27 / v29 …).
    • --restore-globals <file> reads the file, runs npm install -g <pkg>@latest for 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.
    • README's "Migrating a Mac that was already set up ad-hoc" section walks through the recovery sequence with copy-pasteable commands.
    • Re-running the script on an already-bootstrapped Mac produces zero state mutations: no brew install, no chown, no file writes. Read-only probes (brew list, test -w, node -v) are allowed. Exit 0.
    • End-to-end run on mbp13 and 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 (linux/bootstrap.sh) and WSL bootstrappers — separate platforms, separate hardening passes.
    • Windows (bootstrap.ps1) — covered by npm not found during Claude Code / Codex install — fnm not activated in piped execution scope #3 in the same PR.
    • Migrating off Homebrew, off fnm, or off node@22 as the LTS pin.
    • Re-architecting global package management to use volta, asdf, or mise.
    • Auto-running --restore-globals from a cached globals list — restoring is opt-in via the explicit flag.
  3. AojdevStudio commented on May 13, 2026

    @AojdevStudio
    OwnerAuthor

    This was generated by AI during triage.

    Implementation note — AC #4 broadened

    While implementing in #6, AC #4 ("warns on shell startup if node -v is on an odd-line release (v23/v25/v27/v29)") was broadened to a non-LTS check: warn when node -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_MAJOR before the snippet runs to change the pin (e.g. when v24 becomes Active LTS).

    Code lives in macos/zshrc.snippet.sh and macos/lib/node-utils.sh::is_non_lts_node_version. All other ACs in this issue are implemented to spec.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions