diff --git a/package.json b/package.json index dfb73baf..92ae762d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"", "gen:index": "bun run ./scripts/gen-index.ts", + "gen:ecosystem": "bun run --env-file=.env ./scripts/gen-ecosystem.ts", "predev": "bun run ./scripts/gen-index.ts", "dev": "bun --env-file=.env --watch src/server.tsx", "prestart": "bun run ./scripts/gen-index.ts", diff --git a/scripts/gen-ecosystem.ts b/scripts/gen-ecosystem.ts new file mode 100644 index 00000000..03beb2cd --- /dev/null +++ b/scripts/gen-ecosystem.ts @@ -0,0 +1,124 @@ +#!/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, 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 }; + }); +} + +/** 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)); + return plugins.filter((p) => enabled.has(p.appName)).flatMap((p) => pluginPm2Entry(p) ?? []); +} + +/** 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`); diff --git a/src/servers/plugins/ecosystem.ts b/src/servers/plugins/ecosystem.ts index 85f949b3..cc90cea9 100644 --- a/src/servers/plugins/ecosystem.ts +++ b/src/servers/plugins/ecosystem.ts @@ -31,7 +31,7 @@ import type { DiscoveredPlugin } from './manifest'; const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs'); /** One PM2 app entry. Matches what `services.sh` emits for the core processes. */ -type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean }; +export type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean }; /** * The header to write when there is no file to take one from. @@ -91,13 +91,15 @@ function writeEcosystemApps(apps: Pm2App[]): void { } /** - * Ensure the plugin's sidecar has an entry, replacing one that is already there. + * The PM2 entry a plugin's sidecar would get, or null when it has none — most plugins won't. * - * Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved. - * Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't. + * Pure, deliberately: `addPluginToEcosystem` below is the read-modify-write that touches the file for a + * live install, and `scripts/gen-ecosystem.ts` is the other caller, rebuilding the whole file from what + * the database says is installed and enabled. Neither should duplicate the other's idea of what a + * plugin's entry looks like. */ -export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean { - if (!plugin.sidecar) return false; +export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null { + if (!plugin.sidecar) return null; const name = pluginProcessName(plugin.appName); // Relative to the platform, because `cwd` is pinned to it and an absolute path would break the moment @@ -106,12 +108,22 @@ export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean { ? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1) : plugin.sidecar.script; - const entry: Pm2App = - plugin.sidecar.runtime === 'node' - ? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false } - : { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false }; + return plugin.sidecar.runtime === 'node' + ? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false } + : { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false }; +} - const apps = readEcosystemApps().filter((a) => a.name !== name); +/** + * Ensure the plugin's sidecar has an entry, replacing one that is already there. + * + * Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved. + * Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't. + */ +export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean { + const entry = pluginPm2Entry(plugin); + if (!entry) return false; + + const apps = readEcosystemApps().filter((a) => a.name !== entry.name); apps.push(entry); writeEcosystemApps(apps); return true;