#!/bin/bash # Officer — macOS laptop setup. # # The barebones counterpart to scripts/setup/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/setup_mac_light.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/setup_mac_light.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)" # ../.. — this lives in scripts/setup/. See the note in setup.sh: PROJECT_DIR is where .env is written # and where bun install, gen:index, db:push and pm2 are pointed, and none of them fails loudly on the # wrong directory. PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" PG_FORMULA="postgresql@18" PG_DATABASE="officer_dev" NODE_FORMULA="node@22" ENV_FILE="$PROJECT_DIR/.env" ECOSYSTEM="$PROJECT_DIR/ecosystem.mac.light.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/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" # Plain http://localhost needs nothing relaxed to work: browsers treat it as a secure context, so # passkeys, microphone capture and the clipboard are all available without TLS, and origin checking # is already off by default (ALLOW_ANY_ORIGIN). Reaching this from another device is the case that # needs an HTTPS proxy — http://192.168.x.x is not a secure context and those APIs fail there. 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" </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:" # Read the names with node rather than grepping for `name:`. The profile derives its apps from # ecosystem.config.cjs and has no literal name keys to match, so a grep silently lists nothing — # which looks identical to "no services configured". Loading it also exercises the profile's own # consistency checks, which is exactly the moment you want to hear about a drifted include list. ECOSYSTEM_APPS=$(node -e "require('$ECOSYSTEM').apps.forEach(a=>console.log(a.name))" 2>/dev/null) \ || fail "$(basename "$ECOSYSTEM") could not be loaded — run: node -e \"require('./$(basename "$ECOSYSTEM")')\" to see why" for app in $ECOSYSTEM_APPS; 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.light.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.light.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/setup_mac_light.sh" echo ""