Files
platform/scripts/setup_mac.sh
T
pastilhasandClaude Opus 5 37ff7c3103 mac: bring the laptop ecosystem back in step with the architecture
ecosystem.mac.config.cjs was written on 2026-07-28 and broken within days by two
changes it never caught up with, leaving two of its four processes dead:

- it ran `officer-claude` against src/servers/sidecar/claude/index.ts. That file
  is the Anthropic credential proxy; the process that actually spawns `claude`
  is user-instance.ts, which was never started. Chat had a credential holder and
  nothing driving it. Split into officer-anthropic-proxy + officer-agent to
  match the host, and retired the `officer-claude` name that caused it.
- officer-pty pointed at src/servers/api/terminal/pty-sidecar.mjs, which no
  longer exists; the sidecar moved to src/servers/sidecar/pty/index.mjs. The
  terminal could not start at all.

No startup ordering is needed between the proxy and the agent: the agent reads
the proxy secret from disk and, when it is not there yet, warns and re-reads
before the next spawn.

Five sidecars have been added since the file was written (memos, caldav, photos,
notify, wallet). All are excluded, and every exclusion is now listed with its
reason so the next reader can tell "not applicable on macOS" from "forgotten" —
which is the failure this commit is fixing.

setup_mac.sh needed no path corrections; its verification loop derives the
process names from the ecosystem file, so it picks the new list up on its own.
Two gaps closed there:

- it called `pm2 save` without `pm2 startup`, writing a process list that
  nothing reads — a reboot left the machine with nothing running. Added the
  launchd equivalent behind SETUP_BOOT, optional because a laptop is not a
  server, and non-fatal because pm2's launchd integration can want a prompt.
- the closing "not installed on macOS" note listed four omissions when there are
  now fourteen.

Both files parse and every ecosystem entry point resolves to a file that exists.
The launchd path is the one thing not verified here — it cannot be exercised
from the Linux host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:31:06 +00:00

530 lines
22 KiB
Bash
Executable File

#!/bin/bash
# Officer — macOS laptop setup.
#
# The barebones counterpart to scripts/setup.sh (which targets an Ubuntu/Debian server and is left
# alone). This installs only what a laptop workflow needs: the file browser, Claude/opencode chat,
# and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop,
# no sudoers grant, no power-management changes.
#
# EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively:
#
# SETUP_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/setup_mac.sh
#
# SETUP_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI
# SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode CLI
# SETUP_LINK_NODE brew link --force node@22 SETUP_DEPS bun install
# SETUP_PM2 pm2 via npm SETUP_ENV write .env
# SETUP_INIT gen:index + db:push SETUP_SERVICES pm2 startOrRestart
#
# Accepted values: 1/y/yes/true to run, anything else to skip. Unset = ask (or take the default when
# stdin is not a terminal).
#
# This script never calls sudo itself — everything lands under the Homebrew prefix or $HOME. Note
# that Homebrew's own installer does ask for an administrator password on a fresh Mac.
#
# Usage: bash scripts/setup_mac.sh
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; }
skip() { echo -e " - $1"; }
step() { echo ""; echo -e "${BOLD}── $1 ──${NC}"; }
has() { command -v "$1" &>/dev/null; }
# `set -e` is on, so anything allowed to fail must be guarded explicitly — either inside an `if`, or
# with a trailing `|| true`. Note the classic trap this file avoids everywhere: a bare top-level
# `cmd_a && cmd_b` list returns non-zero when cmd_a fails, which aborts the script. Those are all
# written as `if cmd_a; then cmd_b; fi` instead.
FAILURES=()
note_failure() { FAILURES+=("$1"); fail "$1"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PG_FORMULA="postgresql@18"
PG_DATABASE="officer_dev"
NODE_FORMULA="node@22"
ENV_FILE="$PROJECT_DIR/.env"
ECOSYSTEM="$PROJECT_DIR/ecosystem.mac.config.cjs"
# Ask, unless the matching SETUP_* variable already decided. $1 = variable name, $2 = prompt,
# $3 = default (y|n) used for a bare Enter and for non-interactive runs.
confirm() {
local var="$1" prompt="$2" default="$3" preset reply hint
preset="${!var:-}"
if [ -n "$preset" ]; then
case "$preset" in
1|y|Y|yes|YES|true) return 0 ;;
*) return 1 ;;
esac
fi
if [ ! -t 0 ]; then
if [ "$default" = "y" ]; then return 0; else return 1; fi
fi
hint="[y/N]"
if [ "$default" = "y" ]; then hint="[Y/n]"; fi
echo -en " ${BOLD}${prompt}${NC} ${hint}: "
reply=""
read -r reply || true
reply="${reply:-$default}"
case "$reply" in
y|Y|yes|YES) return 0 ;;
*) return 1 ;;
esac
}
# Prompt for a value with a default. Never fails the script.
ask() {
local __var="$1" prompt="$2" default="$3" reply=""
if [ ! -t 0 ]; then
printf -v "$__var" '%s' "$default"
return 0
fi
echo -en " ${BOLD}${prompt}${NC} [${default}]: "
read -r reply || true
printf -v "$__var" '%s' "${reply:-$default}"
}
echo ""
echo "═══════════════════════════════════════════"
echo " Officer — macOS setup"
echo "═══════════════════════════════════════════"
# ─── 0. preflight ─────────────────────────────────────────────────────────────
step "Preflight"
if [ "$(uname -s)" != "Darwin" ]; then
fail "This script is macOS-only. On Linux use scripts/setup.sh."
exit 1
fi
# Running under sudo would install bun/claude/opencode into /var/root and write HOME_DIR=/var/root
# into .env. Nothing here needs root except the symlinks, which prompt individually.
if [ "$(id -u)" = "0" ] || [ -n "${SUDO_USER:-}" ]; then
fail "Do not run this under sudo — run it as your normal user."
exit 1
fi
ok "macOS $(sw_vers -productVersion), running as $(whoami)"
# Xcode Command Line Tools. Not needed to compile anything: the only two native modules, node-pty and
# argon2, both ship darwin-arm64/darwin-x64 prebuilds and fall back to node-gyp only when a prebuild
# is missing for the running arch. (node-pty has no Linux prebuild — that is why setup.sh needs
# build-essential and this one does not.)
#
# They still matter on a fresh Mac, because `git` comes from them and the file browser shells out to
# it. Installing Homebrew below pulls the CLT in, so this is a note, not a gate.
if xcode-select -p &>/dev/null; then
ok "Xcode command line tools ($(xcode-select -p))"
else
warn "Xcode command line tools not found — Homebrew's installer will pull them in"
echo " Standalone: xcode-select --install (also what a fresh Mac needs for git)"
fi
if has brew; then
ok "homebrew ($(brew --prefix))"
elif confirm SETUP_BREW "Homebrew is not installed. Install it?" y; then
if /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [ -x "$candidate" ]; then
eval "$("$candidate" shellenv)" || true
break
fi
done
fi
if has brew; then ok "homebrew installed"; else note_failure "homebrew install failed"; fi
else
skip "homebrew (declined — brew-based steps will be skipped)"
fi
# macOS ships zip, unzip, curl, python3 and openssl (LibreSSL) in the base system — none are
# installed here.
for builtin_tool in zip unzip curl openssl nc; do
if ! has "$builtin_tool"; then warn "$builtin_tool not found (expected to ship with macOS)"; fi
done
# git needs its own check: /usr/bin/git is a stub on a Mac without the Command Line Tools, so
# `command -v git` succeeds while running it only pops the "install developer tools" dialog. The file
# browser shells out to git, so verify it actually executes. Invoking it here is deliberate — on a
# fresh Mac it triggers that install prompt early, rather than at first use inside the app.
if git --version &>/dev/null; then
ok "git ($(git --version 2>/dev/null || true))"
else
warn "git is not usable yet — accept the Command Line Tools prompt, then re-run this script"
fi
# ─── 1. homebrew packages ─────────────────────────────────────────────────────
step "Packages"
NODE_PREFIX=""
brew_install() {
local formula="$1" probe="$2"
if [ -n "$probe" ] && has "$probe"; then
skip "$formula (already present: $(command -v "$probe"))"
return 0
fi
if brew list --formula "$formula" &>/dev/null; then
skip "$formula (already installed)"
return 0
fi
echo " Installing $formula..."
if brew install "$formula"; then ok "$formula installed"; else note_failure "$formula install failed"; fi
}
if ! has brew; then
skip "packages (homebrew unavailable)"
elif confirm SETUP_PACKAGES "Install node@22, bun and ffmpeg via Homebrew?" y; then
# node@22 exactly — package.json's preinstall rejects anything else (`v < 22 || v > 22`), so plain
# `brew install node` (currently v24+) would break `bun install`.
brew_install "$NODE_FORMULA" ""
brew_install bun bun
# ffmpeg/ffprobe: the only external binaries the file browser shells out to besides git and zip.
brew_install ffmpeg ffmpeg
else
skip "packages (declined)"
fi
if has brew; then
NODE_PREFIX="$(brew --prefix "$NODE_FORMULA" 2>/dev/null || true)"
fi
# ─── 2. node on PATH ──────────────────────────────────────────────────────────
step "Node on PATH"
# node@22 is keg-only, so Homebrew does not put it on PATH. `brew link --force` symlinks it into the
# Homebrew prefix (user-owned, already on PATH) — no sudo, and nothing lands in /usr/local.
if ! has brew; then
skip "node link (homebrew unavailable)"
elif [ -z "$NODE_PREFIX" ] || [ ! -x "$NODE_PREFIX/bin/node" ]; then
skip "node link ($NODE_FORMULA not installed)"
elif [ "$(command -v node 2>/dev/null || true)" = "$NODE_PREFIX/bin/node" ]; then
skip "node already resolves to $NODE_FORMULA"
elif confirm SETUP_LINK_NODE "Link $NODE_FORMULA onto PATH (brew link --force)?" y; then
if brew link --force --overwrite "$NODE_FORMULA" >/dev/null 2>&1; then
ok "$NODE_FORMULA linked into $(brew --prefix)/bin"
else
note_failure "brew link $NODE_FORMULA failed — add $NODE_PREFIX/bin to PATH manually"
fi
else
skip "node link (declined)"
fi
# Verify node really is 22 — `bun install` fails its preinstall check otherwise.
if has node; then
NODE_MAJOR="$(node -v 2>/dev/null | cut -d. -f1 | sed 's/^v//' || true)"
if [ "$NODE_MAJOR" = "22" ]; then
ok "node $(node -v)"
else
note_failure "node $(node -v) is on PATH but package.json requires exactly v22"
fi
else
warn "node not on PATH"
fi
# ─── 3. postgres ──────────────────────────────────────────────────────────────
step "PostgreSQL"
# Detect a reachable server first — this machine may already run Postgres in Docker, in which case
# there is nothing to install and nothing to start.
PG_PREFIX=""
PG_RUNNING=0
if nc -z -G 2 127.0.0.1 5432 &>/dev/null; then
PG_RUNNING=1
ok "postgres already reachable on 127.0.0.1:5432 — nothing to install"
else
skip "no postgres on 127.0.0.1:5432"
if ! has brew; then
skip "postgres install (homebrew unavailable)"
# Default yes: nothing is listening, so there is no Docker/remote server to reuse and Homebrew is
# the only way this host gets a database.
elif confirm SETUP_POSTGRES "Install and start $PG_FORMULA via Homebrew?" y; then
brew_install "$PG_FORMULA" ""
PG_PREFIX="$(brew --prefix "$PG_FORMULA" 2>/dev/null || true)"
if [ -n "$PG_PREFIX" ] && [ -x "$PG_PREFIX/bin/pg_isready" ]; then
echo " Starting $PG_FORMULA..."
brew services start "$PG_FORMULA" >/dev/null 2>&1 || true
for _ in $(seq 1 20); do
if "$PG_PREFIX/bin/pg_isready" -h 127.0.0.1 -q 2>/dev/null; then break; fi
sleep 1
done
if "$PG_PREFIX/bin/pg_isready" -h 127.0.0.1 -q 2>/dev/null; then
PG_RUNNING=1
ok "postgres running"
# Homebrew's postgres trusts local connections for the current user, so no password needed.
if "$PG_PREFIX/bin/psql" -h 127.0.0.1 -lqt 2>/dev/null | cut -d'|' -f1 | grep -qw "$PG_DATABASE"; then
skip "database '$PG_DATABASE' exists"
elif "$PG_PREFIX/bin/createdb" -h 127.0.0.1 "$PG_DATABASE" 2>/dev/null; then
ok "database '$PG_DATABASE' created"
else
note_failure "could not create database '$PG_DATABASE'"
fi
else
note_failure "postgres did not become ready — try: brew services start $PG_FORMULA"
fi
fi
else
skip "postgres install (declined — supply a POSTGRES_URL below)"
fi
fi
# ─── 4. pm2 ───────────────────────────────────────────────────────────────────
step "pm2"
# User-local npm prefix so global installs never need sudo.
export PATH="$HOME/.local/bin:$PATH"
if has pm2; then
skip "pm2 ($(command -v pm2))"
elif ! has npm; then
skip "pm2 (npm unavailable)"
elif confirm SETUP_PM2 "Install pm2 (process manager for the server + sidecars)?" y; then
npm config set prefix "$HOME/.local" >/dev/null 2>&1 || true
echo " Installing pm2..."
if npm install -g pm2 >/dev/null 2>&1; then ok "pm2 installed"; else note_failure "pm2 install failed"; fi
else
skip "pm2 (declined)"
fi
# ~/.local/bin is not on the macOS default PATH (see /etc/paths). It is exported for this run, but a
# later `pm2 logs` from a fresh shell needs it permanently.
case ":${PATH}:" in
*":$HOME/.local/bin:"*) ;;
*) warn "add ~/.local/bin to your PATH so pm2 and claude stay available in new shells" ;;
esac
# ─── 5. agents (claude, opencode) ─────────────────────────────────────────────
step "Agents"
if has claude; then
skip "claude ($(command -v claude))"
elif confirm SETUP_CLAUDE "Install Claude Code?" y; then
echo " Installing claude..."
if curl -fsSL https://claude.ai/install.sh | bash; then
if has claude; then ok "claude installed"; else note_failure "claude installed but not on PATH"; fi
else
note_failure "claude install failed"
fi
else
skip "claude (declined)"
fi
# No symlink needed: the claude sidecar resolves its CLI from CLAUDE_BIN, then PATH, then the
# installer's own locations (see resolveClaudeBin in sidecar/claude/claude-manager.ts).
if [ -x "$HOME/.opencode/bin/opencode" ]; then
skip "opencode ($HOME/.opencode/bin/opencode)"
elif confirm SETUP_OPENCODE "Install opencode?" y; then
echo " Installing opencode..."
if curl -fsSL https://opencode.ai/install | bash; then
if [ -x "$HOME/.opencode/bin/opencode" ]; then
ok "opencode installed"
else
note_failure "opencode installed but not at $HOME/.opencode/bin/opencode"
fi
else
note_failure "opencode install failed"
fi
else
skip "opencode (declined — officer-opencode will not start)"
fi
# ─── 6. project dependencies ──────────────────────────────────────────────────
step "Project dependencies"
if ! has bun; then
skip "bun install (bun unavailable)"
elif confirm SETUP_DEPS "Run bun install?" y; then
echo " Running bun install..."
if (cd "$PROJECT_DIR" && bun install); then
ok "dependencies installed"
else
note_failure "bun install failed"
fi
else
skip "bun install (declined)"
fi
# ─── 7. environment (.env) ────────────────────────────────────────────────────
step "Environment (.env)"
WRITE_ENV=0
if [ -f "$ENV_FILE" ]; then
if confirm SETUP_ENV "$ENV_FILE exists. Regenerate it?" n; then WRITE_ENV=1; else skip ".env (kept existing)"; fi
elif confirm SETUP_ENV "Generate .env?" y; then
WRITE_ENV=1
else
skip ".env (declined)"
fi
ENV_PUBLIC_URL=""
if [ "$WRITE_ENV" = "1" ]; then
echo ""
ask ENV_PORT "PORT" "9010"
# PUBLIC_BUILD_ENV=development makes IS_DEV_BUILD true, which short-circuits origin validation
# (isOriginAllowed returns true immediately) and relaxes rate limits and password rules. That is
# what makes plain http://localhost work with no HTTPS reverse proxy in front.
ask ENV_PUBLIC_URL "PUBLIC_URL" "http://localhost:$ENV_PORT"
ask ENV_DATA_PATH "DATA_PATH" "$HOME/.local/data"
ask ENV_ITEMS_DIR "OFFICER_ITEMS_DIR" "$(dirname "$PROJECT_DIR")/officer-items"
# A Docker postgres usually wants a password; brew's trusts the local user. Default to the plain
# local form and let it be edited — this is the one value the script cannot infer reliably.
ask ENV_POSTGRES_URL "POSTGRES_URL" "postgresql://$(whoami)@127.0.0.1:5432/$PG_DATABASE"
# jwt.ts throws at import time unless this is >= 32 chars, so verify rather than trust the pipeline.
JWT_SECRET="$(openssl rand -base64 48 | tr -d '/+=' | head -c 48 || true)"
if [ ${#JWT_SECRET} -lt 32 ]; then
note_failure "could not generate a JWT_SECRET (got ${#JWT_SECRET} chars) — .env not written"
else
mkdir -p "$ENV_DATA_PATH" "$ENV_ITEMS_DIR" || warn "could not create DATA_PATH/OFFICER_ITEMS_DIR"
if cat > "$ENV_FILE" <<ENVFILE
PORT="$ENV_PORT"
JWT_SECRET="$JWT_SECRET"
PUBLIC_URL="$ENV_PUBLIC_URL"
PUBLIC_BUILD_ENV="development"
DATA_PATH="$ENV_DATA_PATH"
OFFICER_ITEMS_DIR="$ENV_ITEMS_DIR"
HOME_DIR="$HOME"
POSTGRES_URL="$ENV_POSTGRES_URL"
ENVFILE
then
# Holds the JWT signing secret and the database URL.
chmod 600 "$ENV_FILE" || warn "could not chmod 600 $ENV_FILE"
ok ".env written to $ENV_FILE (mode 600)"
else
note_failure "could not write $ENV_FILE"
fi
fi
fi
# ─── 8. project initialization ────────────────────────────────────────────────
step "Project initialization"
if ! has bun || [ ! -f "$ENV_FILE" ]; then
skip "initialization (bun or .env missing)"
elif confirm SETUP_INIT "Generate index.gen.html and apply the database schema?" y; then
# index.gen.html is gitignored and built from PUBLIC_URL, so it never exists on a fresh clone.
echo " Generating index.gen.html..."
if (cd "$PROJECT_DIR" && bun run gen:index); then
ok "index.gen.html generated"
else
note_failure "gen:index failed — the app will not serve until this succeeds"
fi
# Officer applies its schema with push; there are no migrations.
echo " Applying database schema..."
if (cd "$PROJECT_DIR" && bun db:push); then
ok "schema applied"
else
note_failure "db:push failed — check POSTGRES_URL and that postgres is reachable"
fi
else
skip "initialization (declined)"
fi
# ─── 9. services ──────────────────────────────────────────────────────────────
step "Services (pm2)"
if ! has pm2 || [ ! -f "$ECOSYSTEM" ]; then
skip "services (pm2 or ecosystem.mac.config.cjs missing)"
elif [ ! -f "$ENV_FILE" ]; then
# Starting without .env gives a server on port 5000 with no database and a JWT_SECRET throw.
skip "services (.env missing — they would crash-loop)"
elif confirm SETUP_SERVICES "Start Officer and its sidecars with pm2?" y; then
echo " Starting services..."
if (cd "$PROJECT_DIR" && pm2 startOrRestart "$ECOSYSTEM"); then
ok "services started"
else
note_failure "pm2 could not start the services — check 'pm2 logs'"
fi
if pm2 save >/dev/null 2>&1; then ok "process list saved"; else warn "pm2 save failed"; fi
# `pm2 save` on its own writes a process list that nothing ever reads. The Linux setup pairs it with
# `pm2 startup`, which installs the boot unit that resurrects that list; without the pair, a reboot
# silently leaves the machine with nothing running. On macOS the equivalent is a launchd agent rather
# than a systemd unit. Kept optional because a laptop is not a server — you may not want the whole
# stack coming back at every login — and non-fatal, because pm2's launchd integration can want an
# elevated prompt that a scripted run should not force.
if confirm SETUP_BOOT "Start Officer automatically at login (pm2 + launchd)?" y; then
if pm2 startup launchd -u "$(whoami)" --hp "$HOME" >/dev/null 2>&1; then
pm2 save >/dev/null 2>&1
ok "services will start at login"
else
warn "Could not install the launchd agent — run: pm2 startup (and follow its instructions)"
fi
else
skip "login startup (declined — 'pm2 startup' enables it later)"
fi
else
skip "services (declined)"
fi
# ─── verification ─────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
echo " Verification"
echo "═══════════════════════════════════════════"
echo ""
check() { if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi; }
echo "Required:"
check node
check npm
check bun
check git
check zip
check ffmpeg
check ffprobe
echo ""
echo "Database:"
if nc -z -G 2 127.0.0.1 5432 &>/dev/null; then ok "postgres reachable on 127.0.0.1:5432"; else fail "postgres — NOT REACHABLE on 127.0.0.1:5432"; fi
echo ""
echo "Agents:"
check claude
if [ -x "$HOME/.opencode/bin/opencode" ]; then ok "opencode"; else fail "opencode — NOT FOUND"; fi
echo ""
echo "Process manager:"
check pm2
if has pm2 && [ -f "$ECOSYSTEM" ]; then
echo ""
echo "Services:"
for app in $(grep -oE "name: *'[^']+'" "$ECOSYSTEM" | sed "s/.*'\(.*\)'/\1/" || true); do
if [ -n "$(pm2 pid "$app" 2>/dev/null | tr -d '[:space:]' || true)" ]; then
ok "$app"
else
fail "$app — not running (pm2 logs $app)"
fi
done
fi
echo ""
echo "═══════════════════════════════════════════"
if [ ${#FAILURES[@]} -eq 0 ]; then
echo -e " ${GREEN}Setup complete${NC}"
else
echo -e " ${YELLOW}Setup finished with ${#FAILURES[@]} problem(s)${NC}"
for f in "${FAILURES[@]}"; do echo " • $f"; done
fi
echo "═══════════════════════════════════════════"
echo ""
echo "Notes:"
echo " • Open ${ENV_PUBLIC_URL:-http://localhost:9010} — the first-run screen creates the owner account"
echo " • Logs: pm2 logs Restart: pm2 restart ecosystem.mac.config.cjs"
echo " • Postgres must be running before the services start, or db:push and boot will fail"
echo " • Not run on macOS: VNC desktop, email sync, music indexer, cliamp audio, and the sidecars"
echo " that front a container or an external service — vault, slskd, headscale, transmission,"
echo " invoiceshelf, memos, photos, caldav, notify, wallet. See ecosystem.mac.config.cjs."
echo " • Pin a specific Claude CLI with CLAUDE_BIN=/path/to/claude in .env if you need to"
echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup_mac.sh"
echo ""