#!/usr/bin/env bun /** * Rebuild ecosystem.config.cjs from scratch: the core processes, plus one PM2 entry for every sidecar * this install actually has enabled — marketplace plugins that ship one, and app-store services. * * ── Where each part of the answer comes from, and why ── * * CORE_PROCESSES is sourced from `scripts/setup/officer-setup/lib/services.sh` rather than duplicated * here — that file is the one officer-setup itself writes from, and a second hardcoded list is a second * thing to keep in sync with it. This shells out to bash to read the array directly, so there is exactly * one list. * * Plugin sidecars come from crossing two facts that live in different places on purpose: `discoverPlugins` * says what is on DISK (a plugin with a `sidecar/` directory), and `listPluginInstalls` says what the * owner has actually installed and enabled in Postgres. Neither alone is the answer — a plugin can be on * disk but never installed, or installed and then disabled without being removed. * * App-store sidecars are the same cross, against `sidecarInstalls`/`CATALOGUE` instead: every catalogue * entry names a `process`, and `src/servers/sidecar//index.ts` is where it lives — a convention, not * a lookup, the same way `officer-claude-code`'s script path is not stored anywhere but its own directory * name. * * ── Why this exists alongside `servers/plugins/ecosystem.ts` ── * * That file does the incremental add/remove at install/uninstall time — a live, running edit. This is * the other direction: rebuild the whole file from what the database says exists, for a fresh clone, * drift, or restoring from a backup. Neither replaces the other. */ import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { CATALOGUE } from '../src/servers/app-store/catalogue'; import { PLATFORM_DIR } from '../src/servers/data-path'; import { discoverPlugins } from '../src/servers/plugins/discover'; import { pluginPm2Entry, type Pm2App } from '../src/servers/plugins/ecosystem'; import { listPluginInstalls, listPluginPorts, listSidecarInstalls } from 'officerdb'; const done = (message: string): never => { console.log(`[gen:ecosystem] ${message}`); process.exit(0); }; const fail = (message: string): never => { console.error(`[gen:ecosystem] ${message}`); process.exit(1); }; /** The single source of truth for the core list is the bash array itself, not a copy of it. */ function coreProcesses(): Pm2App[] { const servicesLib = join(PLATFORM_DIR, 'scripts/setup/officer-setup/lib/services.sh'); const result = spawnSync('bash', ['-c', `source '${servicesLib}' && printf '%s\\n' "\${CORE_PROCESSES[@]}"`], { encoding: 'utf-8', }); if (result.status !== 0) { fail(`could not read CORE_PROCESSES from ${servicesLib}: ${result.stderr || `exit ${result.status}`}`); } return result.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [name, script, args] = line.split('|'); if (!name || !script) return fail(`unreadable CORE_PROCESSES entry: "${line}"`); return { name, script, args, cwd: PLATFORM_DIR, watch: false }; }) .filter((app) => !DISABLED.has(app.name)); } /* Core processes this host does not want, from `OFFICER_DISABLED_SIDECARS` in `.env`. A core process is core because the platform's own features depend on it — which is why the list is a fixed array and not a setting. But "the platform depends on it" is not "every host runs it": fofos is a 1.9 GB machine serving a website, and `officer-opencode` sat there costing 456 MB of that for a harness nobody on it uses. It froze hard twice, with no OOM line in the journal — which is what memory exhaustion looks like when there is not enough headroom left to kill something cleanly. Deliberately NOT a database setting. This file is regenerated during a plugin install, so a host that has dropped a process needs it to stay dropped across one. `.env` is read at generation time and is per-host, which is exactly the scope of the decision. Naming something the platform genuinely needs will break that host — `officer` itself, or `officer-pty` if anyone uses a terminal. Not validated, because which processes are safe to drop depends on what the host is for, and this script cannot know that. */ const DISABLED = new Set( (process.env.OFFICER_DISABLED_SIDECARS ?? '') .split(',') .map((entry) => entry.trim()) .filter(Boolean), ); /** Installed AND enabled AND actually ships a sidecar — all three, or it gets no entry. */ async function pluginEntries(): Promise { const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]); const enabled = new Set(installs.filter((i) => i.enabled).map((i) => i.appName)); /* Ports come from the database, not from a fresh allocation. This script rebuilds the whole file from what is installed, and it must produce the SAME entries the installer produced — allocating here would hand a plugin a different number than the one already written into somebody's nginx configuration. */ const allocations = await listPluginPorts(); const byPlugin = new Map>(); for (const row of allocations) { const current = byPlugin.get(row.appName) ?? {}; current[row.name] = row.port; byPlugin.set(row.appName, current); } return plugins .filter((p) => enabled.has(p.appName)) .flatMap((p) => pluginPm2Entry(p, byPlugin.get(p.appName)) ?? []); } /** Installed AND enabled. Unlike a plugin, every catalogue entry has a `process` — none are filtered out. */ async function appStoreEntries(): Promise { const installs = await listSidecarInstalls(); const enabled = new Set(installs.filter((i) => i.enabled).map((i) => i.sidecarId)); return CATALOGUE.filter((entry) => enabled.has(entry.id)).map((entry) => ({ name: entry.process, script: 'bun', args: `run src/servers/sidecar/${entry.id}/index.ts`, cwd: PLATFORM_DIR, watch: false, })); } const [core, plugins, appStore] = await Promise.all([ Promise.resolve(coreProcesses()), pluginEntries(), appStoreEntries(), ]); const apps = [...core, ...plugins, ...appStore]; // The three sources should never collide by construction (core names are fixed, plugin names are // `officer-`, app-store names come from the catalogue) — but a later entry silently shadowing // an earlier one in the written file is a worse failure than refusing outright. const seen = new Set(); for (const app of apps) { if (seen.has(app.name)) fail(`duplicate pm2 process name: ${app.name} — refusing to write a shadowed entry`); seen.add(app.name); } const HEADER = `// Generated by "bun gen:ecosystem" (scripts/gen-ecosystem.ts). Not in git, and not meant to // be — it describes THIS install. Re-run it any time the database's idea of what is installed and // enabled has moved on from what this file says: a fresh clone, drift, restoring from a backup. // // \`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. `; const lines = apps.map( (app) => ` { name: '${app.name}', script: '${app.script}'` + (app.args ? `, args: '${app.args}'` : '') + `, cwd: '${app.cwd}', watch: ${app.watch} },`, ); const dest = join(PLATFORM_DIR, 'ecosystem.config.cjs'); await Bun.write(dest, `${HEADER}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`); done(`wrote ${dest} — ${core.length} core, ${plugins.length} plugin, ${appStore.length} app-store`);