#!/bin/bash # ============================================================================= # officer-setup — the pm2 ecosystem file, and starting the processes # ============================================================================= # # Definitions only. # # ── The ecosystem file is GENERATED, and is not in git ── # # There used to be four of them — ecosystem.config.cjs, .light., .mac.light. and # a .profile. that the others derived from. A profile deriving from a full list # means the full list has to exist, which means every plugin's process is # described in the repository whether or not anybody installed it, and a test had # to assert that the two files still agreed with each other. # # One generated file removes all of that. It describes exactly the processes this # install runs, it is written once at setup, and nothing in git can drift from # it. A plugin adds its own entry when it is installed. # # ── Why .cjs and not .js ── # # PM2's own convention is ecosystem.config.js, and it would be wrong here: # package.json declares "type": "module", so a .js file in this directory is ESM # and `module.exports` throws "module is not defined in ES module scope". PM2 # require()s the config, so the extension has to say CommonJS out loud. [[ -n "${OFFICER_SETUP_SERVICES_LOADED:-}" ]] && return 0 OFFICER_SETUP_SERVICES_LOADED=1 ecosystem_file() { echo "$(platform_dir)/ecosystem.config.cjs"; } # The processes a core install runs. Everything else is a plugin. # # `officer-pty` is node rather than bun, and that is not an oversight: it loads # node-pty, a native module built against Node's ABI. Everything else is bun. # # `officer-claude-code` was `officer-agent` until 2026-08-13. The old name said # nothing about what it runs, and it sits beside officer-anthropic-proxy — which # is a different process doing a different job — so "the agent" was ambiguous # exactly where it mattered. It spawns `claude`; the name says so now. CORE_PROCESSES=( "officer|bun|start" "officer-anthropic-proxy|bun|run src/servers/sidecar/claude/index.ts" "officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts" "officer-opencode|bun|run src/servers/sidecar/opencode/index.ts" "officer-pty|node|src/servers/sidecar/pty/index.mjs" # Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with # memos and photos when it was slated to become a plugin — the only one of the three never extracted, # so it sat mounted nowhere with its schema commented out. # # Core rather than a plugin because the producers are the platform's own: the job queue, an agent turn # ending, mail arriving. A notifier the owner can uninstall is one those producers cannot rely on. # It boots with no credentials at all — every channel reports `isConfigured() === false` and dispatch # resolves empty — so an install that never sets an APNs key runs it as a no-op rather than crash-looping. "officer-notify|bun|run src/servers/sidecar/notify/index.ts" ) # Calendar and contacts are NOT here. They were core from 2026-08-19 until 2026-08-27, when caldav became # a plugin: it declares its own sidecar, its own `radicale` dependency and its own two screens, and the # generated ecosystem file gains an `officer-caldav` entry when it is installed. A fresh machine runs five # processes instead of seven, and gets a calendar by installing one plugin. write_ecosystem() { local dest entry name script args dest="$(ecosystem_file)" { cat <<'HEADER' // Generated by officer-setup. Not in git, and not meant to be — it describes THIS // install, and the next machine generates its own. // // `cwd` is pinned on every app for two reasons. Bun auto-loads .env from the // working directory (and the pty sidecar does `import 'dotenv/config'`), so // without it a process started from anywhere else comes up with no POSTGRES_URL. // And src/servers/data-path.ts derives the install root as the PARENT of the // working directory, so a wrong cwd does not fail — it relocates data/, // capabilities/ and dockers/ somewhere else entirely. `assertInstallLayout` // refuses to boot when that happens. // // To add a plugin later, add its entry here. Nothing derives this file from // anything, so there is no second list to keep it agreeing with. module.exports = { apps: [ HEADER for entry in "${CORE_PROCESSES[@]}"; do IFS='|' read -r name script args <<<"$entry" printf " { name: '%s', script: '%s', args: '%s', cwd: '%s', watch: false },\n" \ "$name" "$script" "$args" "$(platform_dir)" done cat <<'FOOTER' ], }; FOOTER } >"$dest" chown "${USERNAME}:$(user_group)" "$dest" return 0 } pm2_start() { as_owner "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1 } pm2_save() { as_owner "pm2 save" 2>&1; } # Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than # doing it — so this runs what it prints, which is the whole point of already # being root here. # # `systemd` is hardcoded because that IS the only path today — pm2's own "platform" argument # has to be `launchd` on macOS instead, generating a LaunchAgent plist rather than a systemd # unit, and running it is a separate mechanism (`launchctl load`, not "eval a printed sudo # command"). That is real, separate work rather than a one-line swap, so this skips with a # stated reason on macOS instead of pretending the systemd path could ever produce anything # usable there — matches the same call this codebase already made for the Tailscale exit-node # NIC tuning and the proxy's systemd docker-ordering fix. pm2_enable_startup() { if [[ "$(uname -s)" == "Darwin" ]]; then warn "pm2 boot-persistence needs a launchd plist on macOS, not a systemd unit — not implemented yet" return 1 fi local cmd cmd="$(as_owner "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)" [[ -z "$cmd" ]] && return 1 eval "${cmd#sudo }" } # One line per process: name, status, restarts. pm2_status_lines() { as_owner "pm2 jlist" 2>/dev/null | node -e ' let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { let apps = []; try { apps = JSON.parse(s); } catch { } for (const a of apps) { const st = a.pm2_env?.status ?? "?"; console.log(`${a.name}|${st}|${a.pm2_env?.restart_time ?? 0}`); } });' }