mac: name the laptop build a profile, and derive it like the others

Renamed for parity now that Linux has a light profile too:
  scripts/setup_mac.sh       -> scripts/setup_mac_light.sh
  ecosystem.mac.config.cjs   -> ecosystem.mac.light.config.cjs

The macOS process list was still a hand-copied subset, which is the shape that
broke it: written 2026-07-28, within days it was running the Anthropic proxy
under the name officer-claude with nothing spawning `claude`, and pointing at a
pty entry point that had moved. Both silent. It now declares names and reasons
and reads script/args from ecosystem.config.cjs, so a launch change on the host
reaches it for free.

The include/exclude checks moved into ecosystem.profile.cjs rather than being
copied into the second profile — duplicating the guard rails would have repeated
the mistake they exist to catch. Both profiles were re-tested against a mutated
host ecosystem: renaming an included app and adding an unclassified sidecar each
throw in both, and an unmodified host loads five apps in both.

Kept as two files rather than collapsed into one, even though they currently
produce identical output. The exclusions do not mean the same thing: on macOS
officer-vnc CANNOT run, there being no Xorg; on a Linux light install it could
run fine and you have chosen not to. Merging them would lose that, and they
diverge the moment one profile gains something the other cannot have.

setup_mac_light.sh's verification loop now reads app names with node instead of
grepping for `name:` — the derived profile has no literal keys, so the grep
would have silently listed no services at all, which reads the same as a healthy
install with nothing configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 12:47:27 +00:00
co-authored by Claude Opus 5
parent 1f4dbbb810
commit 0a1766768d
5 changed files with 170 additions and 160 deletions
+24 -51
View File
@@ -1,36 +1,37 @@
// 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 // For a machine that should run the file browser, the terminal and Claude/opencode chat, and nothing
// browser, the terminal, and Claude/opencode chat. Everything else in ecosystem.config.cjs either // else. Paired with `OFFICER_PROFILE=light bash scripts/setup.sh`, which installs only what these
// fronts a container, supervises a daemon, needs an owner-configured external service, or holds // processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
// material a small install has no business holding.
// //
// DERIVED, NOT COPIED — and that is the whole point. ecosystem.mac.config.cjs was a hand-copied // This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
// process list, and within days of being written it was running a sidecar that had been split in two // for the two checks that make a drifted profile fail loudly instead of silently starting less than it
// and pointing at a pty entry point that had moved. Both failures were silent. Here the entry points // claims. To change what runs, edit INCLUDE. To change HOW something runs, edit ecosystem.config.cjs
// come from ecosystem.config.cjs, so a `script`/`args` change on the host reaches this profile for // and every profile follows.
// free, and a REMOVED or RENAMED app throws at load instead of quietly starting nothing.
// //
// To change what the light profile runs, edit LIGHT_APPS. To change how an app is launched, edit // The app itself is unchanged: every API route stays mounted, so features whose sidecars are absent
// ecosystem.config.cjs and both profiles follow. // report themselves unavailable rather than disappearing. A profile decides which processes start, not
// which code ships.
// //
// Start with: pm2 startOrRestart ecosystem.light.config.cjs // Start with: pm2 startOrRestart ecosystem.light.config.cjs
const full = require('./ecosystem.config.cjs'); const { defineProfile } = require('./ecosystem.profile.cjs');
const LIGHT_APPS = [ module.exports = defineProfile({
'officer', // the app itself: SPA, /api, websockets file: 'ecosystem.light.config.cjs',
include: [
'officer', // the app: SPA, /api, websockets
'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream 'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream
'officer-agent', // spawns `claude` — chat is dead without it 'officer-agent', // spawns `claude` — chat is dead without it
'officer-opencode', // the alternative agent 'officer-opencode', // the alternative agent
'officer-pty', // the terminal 'officer-pty', // the terminal
]; ],
// Everything in ecosystem.config.cjs that is deliberately NOT here, with the reason. Kept as data so // Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
// the two lists can be checked against each other below: an app that is in neither is a mistake, and // A light install simply is not running the thing behind it.
// saying so at load beats discovering it when a feature silently does nothing. excluded: {
const EXCLUDED = { 'officer-vnc': 'no desktop to mirror on a light install',
'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-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-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-vault': 'reverse-proxies a self-hosted Vaultwarden container',
@@ -43,33 +44,5 @@ const EXCLUDED = {
'officer-caldav': 'supervises Radicale, which the light profile does not install', '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-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
'officer-wallet': 'holds seed and node credentials', 'officer-wallet': 'holds seed and node credentials',
}; },
});
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 })),
};
-86
View File
@@ -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,
},
],
};
+61
View File
@@ -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',
},
});
+56
View File
@@ -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<string,string>} 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 };
@@ -8,7 +8,7 @@
# #
# EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively: # 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_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI
# SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode 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 # 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. # 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 set -euo pipefail
@@ -54,7 +54,7 @@ PG_FORMULA="postgresql@18"
PG_DATABASE="officer_dev" PG_DATABASE="officer_dev"
NODE_FORMULA="node@22" NODE_FORMULA="node@22"
ENV_FILE="$PROJECT_DIR/.env" 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, # 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. # $3 = default (y|n) used for a bare Enter and for non-interactive runs.
@@ -430,7 +430,7 @@ fi
step "Services (pm2)" step "Services (pm2)"
if ! has pm2 || [ ! -f "$ECOSYSTEM" ]; then 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 elif [ ! -f "$ENV_FILE" ]; then
# Starting without .env gives a server on port 5000 with no database and a JWT_SECRET throw. # 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)" skip "services (.env missing — they would crash-loop)"
@@ -497,7 +497,13 @@ check pm2
if has pm2 && [ -f "$ECOSYSTEM" ]; then if has pm2 && [ -f "$ECOSYSTEM" ]; then
echo "" echo ""
echo "Services:" 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 if [ -n "$(pm2 pid "$app" 2>/dev/null | tr -d '[:space:]' || true)" ]; then
ok "$app" ok "$app"
else else
@@ -519,11 +525,11 @@ echo "════════════════════════
echo "" echo ""
echo "Notes:" echo "Notes:"
echo " • Open ${ENV_PUBLIC_URL:-http://localhost:9010} — the first-run screen creates the owner account" 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 " • 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 " • 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 " 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 " • 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 "" echo ""