no ecosystem files in git; officer-setup generates one
Deleted all four — ecosystem.config.cjs, .light., .mac.light. and the .profile. they derived from. The repository now contains no ecosystem file at all, and .gitignore keeps it that way. officer-setup writes one at the end, describing exactly the six processes a core install runs: officer, officer-anthropic-proxy, officer-agent, officer-opencode, officer-pty and officer-headscale. No profiles, no derivation, no plugins. The four existed because a profile has to subtract from something, so the full list had to name every plugin's process whether or not anybody installed it — and a test then had to assert the two files still agreed. Generating one file removes the subtraction, the second list and the test that policed them. It is .cjs, not the .js PM2's docs use, and that is not a preference: package.json declares "type": "module", so a .js file here is ESM and `module.exports` throws. PM2 require()s the config. Sections 10 (Services) and 11 (Verify) are built on top of it — write, startOrRestart, save, optional boot hook, then check every process is online with a sane restart count AND that the API actually answers on PORT. A process can be `online` and serving nothing, so the port is asked directly rather than inferred. That completes all eleven sections. catalogue.test.ts required both deleted files at import, so it could not even load. Its central assertion — "the store offers exactly what light leaves out" — has no meaning without a full list to subtract from, which is the point of the change. Replaced by two weaker but real checks: the store must not offer a core process, and every process it names must have a sidecar directory to run. The second catches the same typo the old one did without needing a manifest of everything; verified it holds for all 15 catalogue entries. app-store/pm2.ts starts a sidecar with `--only` against this file, which now holds core alone — so it can stop a plugin but cannot start one that was never written in. Marked `[open]` there rather than left to be discovered: appending a plugin's entry is the plugin system's job. Generated one in a scratch directory and required it with node: six apps, correct cwd on each, valid CommonJS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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<string,string>} 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 };
|
||||
@@ -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."
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});'
|
||||
}
|
||||
@@ -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-<dir>` 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user