diff --git a/ecosystem.light.config.cjs b/ecosystem.light.config.cjs index 0dbaa496..2d9ba2c8 100644 --- a/ecosystem.light.config.cjs +++ b/ecosystem.light.config.cjs @@ -1,75 +1,48 @@ -// Linux light profile — the same process set as the Mac laptop build, on a Linux host. +// Linux light profile — the platform without the self-hosted estate around it. // -// For a machine that should run the platform without the self-hosted estate around it: the file -// browser, the terminal, and Claude/opencode chat. Everything else in ecosystem.config.cjs either -// fronts a container, supervises a daemon, needs an owner-configured external service, or holds -// material a small install has no business holding. +// For a machine that should run the file browser, the terminal and Claude/opencode chat, and nothing +// else. Paired with `OFFICER_PROFILE=light bash scripts/setup.sh`, which installs only what these +// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs. // -// DERIVED, NOT COPIED — and that is the whole point. ecosystem.mac.config.cjs was a hand-copied -// process list, and within days of being written it was running a sidecar that had been split in two -// and pointing at a pty entry point that had moved. Both failures were silent. Here the entry points -// come from ecosystem.config.cjs, so a `script`/`args` change on the host reaches this profile for -// free, and a REMOVED or RENAMED app throws at load instead of quietly starting nothing. +// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and +// for the two checks that make a drifted profile fail loudly instead of silently starting less than it +// claims. To change what runs, edit INCLUDE. To change HOW something runs, edit ecosystem.config.cjs +// and every profile follows. // -// To change what the light profile runs, edit LIGHT_APPS. To change how an app is launched, edit -// ecosystem.config.cjs and both profiles follow. +// The app itself is unchanged: every API route stays mounted, so features whose sidecars are absent +// report themselves unavailable rather than disappearing. A profile decides which processes start, not +// which code ships. // // Start with: pm2 startOrRestart ecosystem.light.config.cjs -const full = require('./ecosystem.config.cjs'); +const { defineProfile } = require('./ecosystem.profile.cjs'); -const LIGHT_APPS = [ - 'officer', // the app itself: SPA, /api, websockets - 'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream - 'officer-agent', // spawns `claude` — chat is dead without it - 'officer-opencode', // the alternative agent - 'officer-pty', // the terminal -]; +module.exports = defineProfile({ + file: 'ecosystem.light.config.cjs', -// Everything in ecosystem.config.cjs that is deliberately NOT here, with the reason. Kept as data so -// the two lists can be checked against each other below: an app that is in neither is a mistake, and -// saying so at load beats discovering it when a feature silently does nothing. -const EXCLUDED = { - 'officer-vnc': 'mirrors an Xorg display with x11vnc; a light install has no desktop to mirror', - 'officer-email': 'needs the mbsync/IMAP stack the light profile does not install', - 'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern', - 'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container', - 'officer-slskd': 'supervises the slskd daemon', - 'officer-headscale': 'fronts a headscale server', - 'officer-transmission': 'fronts a transmission daemon', - 'officer-invoiceshelf': 'fronts an InvoiceShelf container', - 'officer-memos': 'needs an owner-configured Memos instance URL and token', - 'officer-photos': 'needs an owner-configured Immich instance URL and API key', - 'officer-caldav': 'supervises Radicale, which the light profile does not install', - 'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about', - 'officer-wallet': 'holds seed and node credentials', -}; + include: [ + 'officer', // the app: SPA, /api, websockets + 'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream + 'officer-agent', // spawns `claude` — chat is dead without it + 'officer-opencode', // the alternative agent + 'officer-pty', // the terminal + ], -const byName = new Map(full.apps.map((app) => [app.name, app])); - -// A name in LIGHT_APPS that the host no longer defines is the exact failure that broke the Mac file. -// Fail loudly at load rather than start a short list and look healthy. -const missing = LIGHT_APPS.filter((name) => !byName.has(name)); -if (missing.length) { - throw new Error( - `ecosystem.light.config.cjs: ${missing.join(', ')} not found in ecosystem.config.cjs — ` + - `the app was renamed or removed. Update LIGHT_APPS.`, - ); -} - -// And an app added to the host that nobody has classified: it belongs in LIGHT_APPS or in EXCLUDED. -// Without this, a new sidecar silently defaults to "not in the light profile" and nobody decides. -const unclassified = full.apps.map((app) => app.name).filter((name) => !LIGHT_APPS.includes(name) && !(name in EXCLUDED)); -if (unclassified.length) { - throw new Error( - `ecosystem.light.config.cjs: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither ` + - `included nor excluded here. Add it to LIGHT_APPS or to EXCLUDED with a reason.`, - ); -} - -// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does -// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls -// back to PORT=5000 with no POSTGRES_URL. -module.exports = { - apps: LIGHT_APPS.map((name) => ({ ...byName.get(name), cwd: __dirname })), -}; + // Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host. + // A light install simply is not running the thing behind it. + excluded: { + 'officer-vnc': 'no desktop to mirror on a light install', + 'officer-email': 'needs the mbsync/IMAP stack the light profile does not install', + 'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern', + 'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container', + 'officer-slskd': 'supervises the slskd daemon', + 'officer-headscale': 'fronts a headscale server', + 'officer-transmission': 'fronts a transmission daemon', + 'officer-invoiceshelf': 'fronts an InvoiceShelf container', + 'officer-memos': 'needs an owner-configured Memos instance URL and token', + 'officer-photos': 'needs an owner-configured Immich instance URL and API key', + 'officer-caldav': 'supervises Radicale, which the light profile does not install', + 'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about', + 'officer-wallet': 'holds seed and node credentials', + }, +}); diff --git a/ecosystem.mac.config.cjs b/ecosystem.mac.config.cjs deleted file mode 100644 index 4927d7b3..00000000 --- a/ecosystem.mac.config.cjs +++ /dev/null @@ -1,86 +0,0 @@ -// macOS process list — the laptop subset of ecosystem.config.cjs. -// -// Only what a Mac install can actually run and what the laptop workflow needs: the file browser, the -// terminal, and Claude/opencode chat. Everything else on the Linux host is either tied to Xorg, tied to -// a container the laptop does not run, or holds material that has no business on a laptop. -// -// KEEP THIS IN STEP WITH ecosystem.config.cjs. It was written on 2026-07-28 and was broken by two -// architecture changes within days, which is the failure mode to watch for: -// - `officer-claude` was split into `officer-anthropic-proxy` (holds the credential) and -// `officer-agent` (spawns `claude`). This file ran only the first under the old name, so chat had a -// credential holder and nothing driving it. -// - the pty sidecar moved to src/servers/sidecar/pty/index.mjs. This file still pointed at -// src/servers/api/terminal/pty-sidecar.mjs, which no longer exists, so the terminal never started. -// -// Deliberately omitted, and why — so the next person can tell "not applicable" from "forgotten": -// officer-vnc mirrors an Xorg display with x11vnc; macOS has no Xorg to mirror. -// officer-email needs the mbsync/IMAP stack that setup_mac.sh does not install. -// officer-music the ffprobe indexer works, but a full ~/Music index is an expensive thing to -// start by default and it is not part of the laptop workflow. -// officer-vault a reverse proxy to a self-hosted Vaultwarden container. -// officer-slskd supervises the slskd daemon. -// officer-headscale fronts a headscale server. -// officer-transmission fronts a transmission daemon. -// officer-invoiceshelf fronts an InvoiceShelf container. -// officer-memos needs an owner-configured Memos instance URL and token. -// officer-photos needs an owner-configured Immich instance URL and API key. -// officer-caldav supervises Radicale, which setup_mac.sh does not install. -// officer-notify the outbound notifier; its producers are the queue and the email/agent -// sidecars, so on a laptop there is nothing to notify about. -// officer-wallet holds seed and node credentials. Not on a laptop. -// -// 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 the pty -// sidecar 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, - }, - // Holds the proxy secret and forwards to api.anthropic.com. Does NOT run agents — the old - // `officer-claude` name covered both jobs and is exactly how this file ended up with half of chat. - { - name: 'officer-anthropic-proxy', - script: 'bun', - args: 'run src/servers/sidecar/claude/index.ts', - cwd, - watch: false, - }, - // The process that actually spawns `claude`. Resolves the owner from the database and the proxy - // secret from disk, so it needs no startup ordering against the proxy above: if the secret is not - // written yet it warns and re-reads before the next spawn. - { - name: 'officer-agent', - script: 'bun', - args: 'run src/servers/sidecar/claude/user-instance.ts', - cwd, - watch: false, - }, - { - name: 'officer-opencode', - script: 'bun', - args: 'run src/servers/sidecar/opencode/index.ts', - cwd, - watch: false, - }, - // node, not bun — same as the Linux host. The pty sidecar is a .mjs that binds node-pty's native - // addon, which is built against node's ABI. - { - name: 'officer-pty', - script: 'node', - args: 'src/servers/sidecar/pty/index.mjs', - cwd, - watch: false, - }, - ], -}; diff --git a/ecosystem.mac.light.config.cjs b/ecosystem.mac.light.config.cjs new file mode 100644 index 00000000..1a352c3e --- /dev/null +++ b/ecosystem.mac.light.config.cjs @@ -0,0 +1,61 @@ +// macOS light profile — the same process set as the Linux light profile, on a laptop. +// +// Paired with scripts/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode +// chat; nothing else. +// +// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this +// file specifically: written on 2026-07-28 as a hand-copied process list, it was broken within days by +// two changes it could not see. It ran `officer-claude` against the Anthropic proxy's entry point +// while the process that actually spawns `claude` was never started, and it pointed at a pty sidecar +// that had moved. Both failures were silent — the processes simply did not come up. See +// ecosystem.profile.cjs for the checks that now make that loud. +// +// WHY THIS IS SEPARATE FROM ecosystem.light.config.cjs, given both currently run the same five apps: +// the exclusions mean different things. On macOS officer-vnc cannot run — there is no Xorg to mirror. +// On a Linux light install it could run perfectly well; you have chosen not to. Those diverge as soon +// as one profile gains something the other cannot have, and collapsing them would lose the reason. +// +// Start with: pm2 startOrRestart ecosystem.mac.light.config.cjs + +const { defineProfile } = require('./ecosystem.profile.cjs'); + +module.exports = defineProfile({ + file: 'ecosystem.mac.light.config.cjs', + + include: [ + 'officer', // the app: SPA, /api, websockets + 'officer-anthropic-proxy', // holds the Anthropic credential, forwards to api.anthropic.com + // Spawns `claude`. Reads the proxy secret from disk, so it needs no ordering against the proxy + // above: if the secret is not written yet it warns and re-reads before the next spawn. + 'officer-agent', + 'officer-opencode', // the alternative agent + // The terminal. Runs under node rather than bun — node-pty binds a native addon built against + // node's ABI. That detail lives in ecosystem.config.cjs, not here. + 'officer-pty', + ], + + excluded: { + // Cannot run on macOS at all. + 'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg', + + // Would run, but needs something setup_mac_light.sh deliberately does not install. + 'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install', + 'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install', + 'officer-music': 'the ffprobe indexer works, but a full ~/Music index is expensive to start by default', + + // Fronts a container or daemon a laptop is not running. + 'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container', + 'officer-slskd': 'supervises the slskd daemon', + 'officer-headscale': 'fronts a headscale server', + 'officer-transmission': 'fronts a transmission daemon', + 'officer-invoiceshelf': 'fronts an InvoiceShelf container', + + // Needs an owner-configured external service. + 'officer-memos': 'needs an owner-configured Memos instance URL and token', + 'officer-photos': 'needs an owner-configured Immich instance URL and API key', + + // Deliberate, for what it holds or who feeds it. + 'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about', + 'officer-wallet': 'holds seed and node credentials; not on a laptop', + }, +}); diff --git a/ecosystem.profile.cjs b/ecosystem.profile.cjs new file mode 100644 index 00000000..ea48a0db --- /dev/null +++ b/ecosystem.profile.cjs @@ -0,0 +1,56 @@ +// Shared machinery for the pm2 install profiles (ecosystem.light.config.cjs, +// ecosystem.mac.light.config.cjs). +// +// A profile is a SUBSET of ecosystem.config.cjs, declared as names plus reasons. It never restates how +// a process is launched — `script` and `args` are read from the host file at load — because a +// hand-copied process list is exactly what failed here: the macOS list was written on 2026-07-28 and +// within days was starting a sidecar that had been split in two and pointing at a pty entry point that +// had moved. Neither failure said anything; the processes simply did not come up. +// +// So the rule is: ecosystem.config.cjs is the only place a launch command is written down, and a +// profile only decides which of them to run. +// +// Two consistency checks, both of which turn a silent breakage into a loud one at load: +// 1. a name the profile INCLUDES that the host no longer defines — the app was renamed or removed +// 2. an app the host defines that the profile neither includes nor excludes — a new sidecar, which +// must be classified deliberately rather than defaulting to absent because nobody noticed +// +// The second is the one that matters over time. Without it, every sidecar added to the host silently +// stays out of every profile, and the profiles quietly stop meaning what their comments claim. + +/** + * @param {object} spec + * @param {string} spec.file this profile's filename, for error messages + * @param {string[]} spec.include app names to run, in start order + * @param {Record} spec.excluded app name → why it is not in this profile + */ +function defineProfile({ file, include, excluded }) { + const full = require('./ecosystem.config.cjs'); + const byName = new Map(full.apps.map((app) => [app.name, app])); + + const missing = include.filter((name) => !byName.has(name)); + if (missing.length) { + throw new Error( + `${file}: ${missing.join(', ')} not found in ecosystem.config.cjs — the app was renamed or ` + + `removed. Update this profile's include list.`, + ); + } + + const unclassified = full.apps + .map((app) => app.name) + .filter((name) => !include.includes(name) && !(name in excluded)); + if (unclassified.length) { + throw new Error( + `${file}: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither included nor ` + + `excluded here. Add it to the include list, or to the excluded map with a reason.`, + ); + } + + // `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does + // `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls + // back to PORT=5000 with no POSTGRES_URL. __dirname is the repo root — this file sits beside + // ecosystem.config.cjs. + return { apps: include.map((name) => ({ ...byName.get(name), cwd: __dirname })) }; +} + +module.exports = { defineProfile }; diff --git a/scripts/setup_mac.sh b/scripts/setup_mac_light.sh similarity index 95% rename from scripts/setup_mac.sh rename to scripts/setup_mac_light.sh index ac18e07c..784507a7 100755 --- a/scripts/setup_mac.sh +++ b/scripts/setup_mac_light.sh @@ -8,7 +8,7 @@ # # 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_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/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 @@ -22,7 +22,7 @@ # 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 +# Usage: bash scripts/setup_mac_light.sh set -euo pipefail @@ -54,7 +54,7 @@ PG_FORMULA="postgresql@18" PG_DATABASE="officer_dev" NODE_FORMULA="node@22" ENV_FILE="$PROJECT_DIR/.env" -ECOSYSTEM="$PROJECT_DIR/ecosystem.mac.config.cjs" +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. @@ -430,7 +430,7 @@ fi step "Services (pm2)" if ! has pm2 || [ ! -f "$ECOSYSTEM" ]; then - skip "services (pm2 or ecosystem.mac.config.cjs missing)" + skip "services (pm2 or ecosystem.mac.light.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)" @@ -497,7 +497,13 @@ check pm2 if has pm2 && [ -f "$ECOSYSTEM" ]; then echo "" echo "Services:" - for app in $(grep -oE "name: *'[^']+'" "$ECOSYSTEM" | sed "s/.*'\(.*\)'/\1/" || true); do + # 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 @@ -519,11 +525,11 @@ 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 " • 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.config.cjs." +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_mac.sh" +echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup_mac_light.sh" echo ""