Skip to content
Merged
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="assets/architecture.png" alt="dev-bootstrap installation architecture" width="500">
</p>
Expand Down
18 changes: 14 additions & 4 deletions bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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@') {
Expand Down Expand Up @@ -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) {
Expand Down
216 changes: 188 additions & 28 deletions macos/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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@<pin> (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="# ------------------------------"
Expand All @@ -19,7 +41,6 @@ require_cmd() {
}

ensure_line_in_file_once() {
# Usage: ensure_line_in_file_once <file> <line>
local file="$1"
local line="$2"
mkdir -p "$(dirname "$file")"
Expand Down Expand Up @@ -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
Expand All @@ -64,48 +218,54 @@ 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)"
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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):"
Expand Down
Loading
Loading