diff --git a/.gitignore b/.gitignore index 3522b43b..c6fedb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,8 @@ public/plugins/ # Written by officer-setup.sh; per-machine. scripts/setup/officer-setup/.setup-progress + +# Generated by officer-setup, describing THIS install's processes. Never committed: +# the repository has no ecosystem file at all any more, and the next machine +# generates its own. See scripts/setup/officer-setup/lib/services.sh. +ecosystem.config.cjs diff --git a/CLAUDE.md b/CLAUDE.md index 0efaa5d8..271d200c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ One Bun process (`src/server.tsx`) serves everything: Long-running and privileged work lives in **sidecars**: separate processes that dial back in over `/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them -(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`, +(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`, `officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`, `officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea` diff --git a/ecosystem-files/ecosystem.config.cjs b/ecosystem-files/ecosystem.config.cjs deleted file mode 100644 index 1475d9a2..00000000 --- a/ecosystem-files/ecosystem.config.cjs +++ /dev/null @@ -1,157 +0,0 @@ -module.exports = { - apps: [ - { - name: 'officer', - script: 'bun', - args: 'start', - watch: false, - }, - // The Anthropic credential proxy. Despite the old name (`officer-claude`) this process does NOT - // run agents — it holds the proxy secret and forwards to api.anthropic.com. The process that runs - // agents is `officer-agent` below. - { - name: 'officer-anthropic-proxy', - script: 'bun', - args: 'run src/servers/sidecar/claude/index.ts', - watch: false, - }, - // The process that actually runs `claude`. It used to be spawned on demand by the main server, - // which made every agent session a grandchild of `officer` and killed it on every restart. As a PM2 - // peer it survives them. It resolves the owner from the database and the proxy secret from the - // proxy's state file, so it needs nothing from `officer` in order to start. - { - name: 'officer-agent', - script: 'bun', - args: 'run src/servers/sidecar/claude/user-instance.ts', - watch: false, - }, - { - name: 'officer-opencode', - script: 'bun', - args: 'run src/servers/sidecar/opencode/index.ts', - watch: false, - }, - { - name: 'officer-email', - script: 'bun', - args: 'run src/servers/sidecar/email/index.ts', - watch: false, - }, - // The only sidecar run by `node` rather than `bun`, and the only one that is not TypeScript: node-pty - // is a native addon. It also does not use sidecar/connect.ts, and carries its own copy of the - // reconnect loop. - { - name: 'officer-pty', - script: 'node', - args: 'src/servers/sidecar/pty/index.mjs', - watch: false, - }, - { - name: 'officer-vnc', - script: 'bun', - args: 'run src/servers/sidecar/vnc/index.ts', - watch: false, - }, - { - name: 'officer-music', - script: 'bun', - args: 'run src/servers/sidecar/music/index.ts', - watch: false, - }, - { - name: 'officer-vault', - script: 'bun', - args: 'run src/servers/sidecar/vault/index.ts', - watch: false, - }, - { - name: 'officer-slskd', - script: 'bun', - args: 'run src/servers/sidecar/slskd/index.ts', - watch: false, - }, - { - name: 'officer-headscale', - script: 'bun', - args: 'run src/servers/sidecar/headscale/index.ts', - watch: false, - }, - { - name: 'officer-transmission', - script: 'bun', - args: 'run src/servers/sidecar/transmission/index.ts', - watch: false, - }, - // The books. Wraps a self-hosted InvoiceShelf. Instances, their Sanctum tokens and the company each one - // is pinned to are set by the owner from /invoices/settings and stored encrypted in - // `invoiceshelf_accounts` — read here, never from the environment, because Bun auto-loads `.env` into - // every process in this directory and `officer` would hold the token too. - { - name: 'officer-invoiceshelf', - script: 'bun', - args: 'run src/servers/sidecar/invoiceshelf/index.ts', - watch: false, - }, - // Video. Wraps a self-hosted Jellyfin. Servers, and the access token each one is signed in with, are set - // by the owner from /jellyfin and stored encrypted in `jellyfin_servers` — read here, never from the - // environment. Video only: Officer's own player owns audio. - { - name: 'officer-jellyfin', - script: 'bun', - args: 'run src/servers/sidecar/jellyfin/index.ts', - watch: false, - }, - // Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the - // owner from the UI and stored in `service_connections` — read here, never from the environment. - { - name: 'officer-memos', - script: 'bun', - args: 'run src/servers/sidecar/memos/index.ts', - watch: false, - }, - // Code hosting. Wraps a self-hosted Gitea. The instance URL and its personal access token are set by - // the owner from /gitea and stored in `service_connections` — read here, never from the environment. - { - name: 'officer-gitea', - script: 'bun', - args: 'run src/servers/sidecar/gitea/index.ts', - watch: false, - }, - // Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the - // collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic - // against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's; - // the platform authenticates and forwards. See docs/nextcloud-replacement.md. - { - name: 'officer-caldav', - script: 'bun', - args: 'run src/servers/sidecar/caldav/index.ts', - watch: false, - }, - // The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from - // /photos/settings and stored encrypted in `photos_config` — read here, never from the environment, - // because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too. - { - name: 'officer-photos', - script: 'bun', - args: 'run src/servers/sidecar/photos/index.ts', - watch: false, - }, - // The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so - // it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks. - // The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the - // Discord webhook, behind one interface. A sidecar rather than platform code because the producers - // are spread across sidecars, and a platform-owned notifier would make every one of them call back in. - { - name: 'officer-notify', - script: 'bun', - args: 'run src/servers/sidecar/notify/index.ts', - watch: false, - }, - { - name: 'officer-wallet', - script: 'bun', - args: 'run src/servers/sidecar/wallet/index.ts', - watch: false, - }, - ], -}; diff --git a/ecosystem-files/ecosystem.light.config.cjs b/ecosystem-files/ecosystem.light.config.cjs deleted file mode 100644 index 64522568..00000000 --- a/ecosystem-files/ecosystem.light.config.cjs +++ /dev/null @@ -1,55 +0,0 @@ -// Linux light profile — the platform without the self-hosted estate around it. -// -// 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/setup.sh`, which installs only what these -// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs. -// -// 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. -// -// 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 { defineProfile } = require('./ecosystem.profile.cjs'); - -module.exports = defineProfile({ - file: 'ecosystem.light.config.cjs', - - 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 - ], - - // 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: { - // Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs - // nothing installed locally. True, and beside the point: a baseline process appears in the Permissions - // screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea - // access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which - // makes "is Gitea here" one question with one answer instead of two that disagree. - 'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token', - '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-jellyfin': 'fronts a Jellyfin 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-files/ecosystem.mac.light.config.cjs b/ecosystem-files/ecosystem.mac.light.config.cjs deleted file mode 100644 index 3b68b682..00000000 --- a/ecosystem-files/ecosystem.mac.light.config.cjs +++ /dev/null @@ -1,68 +0,0 @@ -// macOS light profile — the same process set as the Linux light profile, on a laptop. -// -// Paired with scripts/setup/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', - - // Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed - // locally — it points at a remote instance over the network — but a baseline process shows up in the dock - // and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It - // is an app-store install now: `existing` mode, URL and token, same as any other remote service. - 'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token', - - // 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', - 'officer-jellyfin': 'fronts a Jellyfin 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-files/ecosystem.profile.cjs b/ecosystem-files/ecosystem.profile.cjs deleted file mode 100644 index 1592ffb8..00000000 --- a/ecosystem-files/ecosystem.profile.cjs +++ /dev/null @@ -1,85 +0,0 @@ -// 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 - */ -// The directory holding the platform's package.json, found by walking up from this file. Independent of -// where in the tree this config is kept, and of where pm2 was invoked from. -function repoRoot() { - const { existsSync, readFileSync } = require('node:fs'); - const { dirname, join } = require('node:path'); - let dir = __dirname; - for (;;) { - const manifest = join(dir, 'package.json'); - if (existsSync(manifest)) { - try { - if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir; - } catch { - // Unparseable is not ours; keep walking. - } - } - const up = dirname(dir); - if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname); - dir = up; - } -} - -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 the default PORT with no POSTGRES_URL. - // - // It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of - // the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not - // fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it. - // - // This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside - // ecosystem.config.cjs". That stopped being true the moment these files were moved into - // ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is - // true wherever this file ends up living. - return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) }; -} - -module.exports = { defineProfile }; diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index 277cc15d..d0486c6d 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -52,6 +52,8 @@ source "$SCRIPT_DIR/officer-setup/lib/env.sh" source "$SCRIPT_DIR/officer-setup/lib/secrets.sh" # shellcheck source=officer-setup/lib/build.sh source "$SCRIPT_DIR/officer-setup/lib/build.sh" +# shellcheck source=officer-setup/lib/services.sh +source "$SCRIPT_DIR/officer-setup/lib/services.sh" trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR @@ -632,10 +634,105 @@ if ! skip; then fi # ============================================================================= -# NOT BUILT YET +# 10. Services # ============================================================================= -# 10 Services pm2 startOrRestart · save · startup -# 11 Verify are the processes actually up + +step "Services" +if ! skip; then + echo "" + info "pm2 — $(ecosystem_file)" + echo " The ecosystem file is GENERATED, not checked in. It describes this" + echo " install and nothing else, so nothing in git can drift from it." + echo "" + echo " six processes:" + for entry in "${CORE_PROCESSES[@]}"; do + IFS='|' read -r _name _script _args <<<"$entry" + printf " %-24s %s %s\n" "$_name" "$_script" "$_args" + done + echo "" + echo " Nothing else. Every plugin adds its own entry when it is installed." + echo "" + + if confirm "Write it and start them?"; then + write_ecosystem + ok "written — $(ecosystem_file)" + + if OUT="$(pm2_start)"; then + ok "processes started" + pm2_save >/dev/null 2>&1 && ok "process list saved (survives a pm2 restart)" + + echo "" + if confirm "Start them on boot too?"; then + if pm2_enable_startup; then + ok "pm2 will resurrect them at boot" + SUMMARY+=("Services: 6 processes started, enabled at boot") + else + warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it" + SUMMARY+=("Services: 6 processes started; boot hook NOT enabled") + fi + else + SUMMARY+=("Services: 6 processes started; not enabled at boot") + fi + else + warn "pm2 did not start cleanly" + echo "$OUT" | tail -12 | sed 's/^/ /' + SUMMARY+=("Services: FAILED to start — see the output above") + fi + else + warn "skipped by request" + SUMMARY+=("Services: SKIPPED by request") + fi + step_ok +fi + +# ============================================================================= +# 11. Verify +# ============================================================================= + +step "Verify" +if ! skip; then + echo "" + info "Are the processes actually up?" + echo "" + + VERIFY_BAD=0 + while IFS='|' read -r vname vstatus vrestarts; do + [[ -z "$vname" ]] && continue + if [[ "$vstatus" == "online" ]]; then + if (( vrestarts > 3 )); then + warn "$(printf '%-24s online, but restarted %s times — check: pm2 logs %s' "$vname" "$vrestarts" "$vname")" + VERIFY_BAD=$((VERIFY_BAD + 1)) + else + ok "$(printf '%-24s online' "$vname")" + fi + else + warn "$(printf '%-24s %s — check: pm2 logs %s' "$vname" "$vstatus" "$vname")" + VERIFY_BAD=$((VERIFY_BAD + 1)) + fi + done < <(pm2_status_lines) + + echo "" + # A process can be `online` and still be failing to serve — a restart loop takes + # a few seconds to show up in the counter, and the app can be up with a broken + # database. So the port is asked directly. + if curl -fsS --max-time 5 "http://127.0.0.1:${ENV_PORT:-9000}/api" >/dev/null 2>&1; then + ok "the API answers on 127.0.0.1:${ENV_PORT:-9000}" + SUMMARY+=("Verify: API answering on port ${ENV_PORT:-9000}") + else + warn "nothing answered on 127.0.0.1:${ENV_PORT:-9000}/api" + echo " pm2 logs officer is where the reason will be." + VERIFY_BAD=$((VERIFY_BAD + 1)) + SUMMARY+=("Verify: the API did NOT answer on port ${ENV_PORT:-9000}") + fi + + if (( VERIFY_BAD == 0 )); then + echo "" + ok "Officer is running. Open ${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT:-9000}} and the" + echo " first-run screen will create the owner account." + fi + step_ok +fi + echo "" echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet." diff --git a/scripts/setup/officer-setup/lib/services.sh b/scripts/setup/officer-setup/lib/services.sh new file mode 100644 index 00000000..f5c2ddce --- /dev/null +++ b/scripts/setup/officer-setup/lib/services.sh @@ -0,0 +1,110 @@ +#!/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. +CORE_PROCESSES=( + "officer|bun|start" + "officer-anthropic-proxy|bun|run src/servers/sidecar/claude/index.ts" + "officer-agent|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" + "officer-headscale|bun|run src/servers/sidecar/headscale/index.ts" +) + +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() { + sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1 +} + +pm2_save() { sudo -u "$USERNAME" 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. +pm2_enable_startup() { + local cmd + cmd="$(sudo -u "$USERNAME" bash -c "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() { + sudo -u "$USERNAME" 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}`); + } + });' +} diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index 71d20ac3..0db5a2e3 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -1,34 +1,53 @@ import { describe, expect, it } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import { CATALOGUE, byId } from './catalogue'; import { CAPABILITIES } from '../capabilities/registry'; // The catalogue is a hand-written list describing machinery that lives elsewhere, which is the shape of -// thing that rots silently. These tests pin it to the three sources it claims to agree with: -// ecosystem.config.cjs, the light profile, and the capability registry. +// thing that rots silently. These tests pin it to what it claims to agree with. // // The intent is that adding a sidecar to the estate and forgetting the app store FAILS HERE, rather than // the sidecar being quietly uninstallable and nobody noticing for a release. +// +// ── What changed on 2026-08-13 ── +// +// Two of these tests derived the catalogue from `ecosystem.config.cjs` minus `ecosystem.light.config.cjs` +// — "the app store offers exactly what light leaves out". That was the right check while those files +// existed, and they do not any more: the ecosystem file is GENERATED at setup, describes only the six +// core processes, and is not in git. There is no longer a list of every possible process to subtract +// from, which is the point — a plugin's process is described when it is installed, not before. +// +// So the derivation is gone and two weaker but still real checks replace it: the store must not offer a +// CORE process, and every process it names must have a sidecar directory to run. The second is the +// stronger of the two — it catches the typo the old test caught, without needing a manifest of +// everything. -const full = (require('../../../ecosystem.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name); -const light = (require('../../../ecosystem.light.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name); +// The processes a core install runs, mirroring CORE_PROCESSES in +// scripts/setup/officer-setup/lib/services.sh. Duplicated deliberately: the generator is shell and this +// is a test, and the alternative is the test reading a file the repository does not contain. +const CORE = [ + 'officer', + 'officer-anthropic-proxy', + 'officer-agent', + 'officer-opencode', + 'officer-pty', + 'officer-headscale', +]; describe('the catalogue against the real estate', () => { - it('offers exactly the processes the light profile leaves out', () => { - // This is the definition of the app store: light is the baseline, everything else is installable. - const notInLight = full.filter((name) => !light.includes(name)).sort(); - const offered = CATALOGUE.map((e) => e.process).sort(); - - expect(offered).toEqual(notInLight); - }); - - it('names a process that actually exists in the ecosystem', () => { - // A typo here would install nothing and report success. - for (const entry of CATALOGUE) expect(full).toContain(entry.process); - }); - it('does not offer to install the baseline', () => { // "Uninstall chat" is not a thing the store should be able to express. - for (const entry of CATALOGUE) expect(light).not.toContain(entry.process); + for (const entry of CATALOGUE) expect(CORE).not.toContain(entry.process); + }); + + it('names a process that has a sidecar to run', () => { + // A typo here would install nothing and report success. Every process is `officer-` under + // src/servers/sidecar/, which is what the generated ecosystem entry would point `args` at. + for (const entry of CATALOGUE) { + const dir = entry.process.replace(/^officer-/, ''); + expect(existsSync(join(import.meta.dir, '../sidecar', dir))).toBe(true); + } }); }); diff --git a/src/servers/app-store/pm2.ts b/src/servers/app-store/pm2.ts index 282fd1f4..07b8202e 100644 --- a/src/servers/app-store/pm2.ts +++ b/src/servers/app-store/pm2.ts @@ -20,6 +20,13 @@ export type Pm2Result = { ok: true } | { ok: false; error: string }; +// The file officer-setup GENERATES at $OFFICER_ROOT/platform. It is not in the repository. +// +// `[open]` As of 2026-08-13 it contains the SIX core processes and nothing else, so +// `pm2 start ecosystem.config.cjs --only officer-jellyfin` finds no such app and does nothing. Installing +// a plugin has to append its entry here before starting it — that is the plugin system's job and it is +// not built. Until it is, the app store can stop a running sidecar but cannot start one that was never +// in the file. const ECOSYSTEM = 'ecosystem.config.cjs'; async function pm2(args: string[], cwd: string): Promise<{ code: number; out: string }> {