Files
platform/scripts/gen-ecosystem.ts
pastilhas 518d329c29 the platform can allocate a plugin a port, and remember it
A sidecar already gets a port and nothing here changes that: it binds 0, the
kernel picks, it announces the number. That is right for what it is - a private
detail between two processes, loopback, re-chosen every restart, and nothing
outside Officer needs it.

A port something ELSE connects to is a different thing. A plugin serving a
website has to be reachable from nginx, which means the number must survive a
restart and be knowable by whoever writes the proxy configuration. Ephemeral
fails both, which is why the first site plugin was going to hardcode 3011 and
hope.

So: `extraPorts: [{ name: 'site', preferred: 3011 }]` in a manifest, allocated at
install, persisted in plugin_ports, and delivered as PORT_SITE in the sidecar's
pm2 environment. Environment rather than the registration socket because a socket
answer arrives after the process has already had to decide what to bind, and pm2
is the only thing that can tell a process something before it starts.

What the platform guarantees is the part per-plugin config could never do: no two
plugins are ever handed the same number. That is a unique index rather than a
convention, because a collision is not a duplicate row - it is two services
fighting over a listener where the loser fails to bind and the winner is whoever
started first.

`preferred` is a wish, not a claim. A plugin usually arrives after the thing that
connects to it, and moving that configuration is work the platform can spare
somebody; but a preference that could fail an install would be a claim wearing a
softer word.

Allocations survive uninstall and are released only by purge. The number is
written down where this database cannot see it, so handing it to a different
plugin because this one was briefly removed points that configuration at the
wrong service.

Pm2App gained `env`, emitted as JSON rather than hand-quoted: a port is a number
today, but this is the seam where a path arrives eventually, and one apostrophe
in a value produces a file PM2 cannot parse - which takes every process on the
machine down, not just the plugin's.

Verified: stable across calls, distinct across plugins, released cleanly,
honours a preference, and a plugin declaring no ports gets no env key at all.
2026-09-06 10:47:26 +01:00

167 lines
7.9 KiB
TypeScript

#!/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/<id>/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<Pm2App[]> {
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<string, Record<string, number>>();
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<Pm2App[]> {
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-<appName>`, 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<string>();
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`);