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:
2026-08-13 02:35:12 +00:00
co-authored by Claude Opus 5
parent b075f1f882
commit 62cbf510cb
10 changed files with 260 additions and 387 deletions
+100 -3
View File
@@ -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."
+110
View File
@@ -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}`);
}
});'
}