diff --git a/ecosystem.mac.config.cjs b/ecosystem.mac.config.cjs new file mode 100644 index 00000000..a6bd235f --- /dev/null +++ b/ecosystem.mac.config.cjs @@ -0,0 +1,50 @@ +// macOS process list — the laptop subset of ecosystem.config.cjs. +// +// Only what a Mac install can actually run and what the laptop workflow needs (file browser + +// Claude/opencode chat + terminal). Deliberately omitted: +// officer-vnc — mirrors an Xorg :0 with x11vnc; there is no Xorg on macOS. +// officer-email — needs the mbsync/IMAP stack that setup_mac.sh does not install. +// officer-music — the indexer is ffprobe-driven and works, but it is not part of the laptop +// workflow and a full ~/Music index is an expensive thing to start by default. +// +// Start with: pm2 startOrRestart ecosystem.mac.config.cjs +// The Linux host keeps using ecosystem.config.cjs; neither file references the other. +// +// `cwd` is pinned on every app because Bun auto-loads .env from the working directory (and +// pty-sidecar.mjs does `import 'dotenv/config'`). Without it, starting pm2 from anywhere other than +// the repo root silently falls back to PORT=5000 with no POSTGRES_URL. + +const cwd = __dirname; + +module.exports = { + apps: [ + { + name: 'officer', + script: 'bun', + args: 'start', + cwd, + watch: false, + }, + { + name: 'officer-claude', + script: 'bun', + args: 'run src/servers/sidecar/claude/index.ts', + cwd, + watch: false, + }, + { + name: 'officer-opencode', + script: 'bun', + args: 'run src/servers/sidecar/opencode/index.ts', + cwd, + watch: false, + }, + { + name: 'officer-pty', + script: 'node', + args: 'src/servers/api/terminal/pty-sidecar.mjs', + cwd, + watch: false, + }, + ], +}; diff --git a/scripts/setup_mac.sh b/scripts/setup_mac.sh new file mode 100755 index 00000000..b4c76900 --- /dev/null +++ b/scripts/setup_mac.sh @@ -0,0 +1,510 @@ +#!/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" </dev/null 2>&1; then ok "process list saved"; else warn "pm2 save failed"; 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 installed on macOS: VNC desktop, email sync, music indexer, cliamp audio" +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 ""