add gen:ecosystem script to rebuild ecosystem.config.cjs from the database
bun gen:ecosystem writes the whole file from three sources: CORE_PROCESSES read directly out of scripts/setup/officer-setup/lib/services.sh (shelled out to bash, so there is exactly one list rather than a TS copy to keep in sync by hand), marketplace plugins that are installed AND enabled in Postgres and actually ship a sidecar/, and app-store services that are installed AND enabled, using each catalogue entry's `process` name against the src/servers/sidecar/<id>/index.ts convention. Extracted `pluginPm2Entry` (a pure DiscoveredPlugin -> Pm2App) out of addPluginToEcosystem so the live install/uninstall path and this from-scratch rebuild share one definition of what a plugin's entry looks like, rather than two that can drift apart. addPluginToEcosystem's own behavior is unchanged. Complements, not replaces, the existing incremental add/remove in servers/plugins/ecosystem.ts — that edits the live file at install time; this regenerates the whole thing, for a fresh clone, drift, or restoring from a backup. Found while testing against this machine's real file, worth flagging rather than silently working around: officer-marketplace (the marketplace.officer.dev process, src/servers/marketplace/server.tsx) is in this machine's ecosystem.config.cjs by hand and is modeled by neither the plugin system nor the app-store catalogue, so this script does not know about it and would drop its entry on a real run. Left alone rather than guessed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"scripts": {
|
"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); }\"",
|
"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: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",
|
"predev": "bun run ./scripts/gen-index.ts",
|
||||||
"dev": "bun --env-file=.env --watch src/server.tsx",
|
"dev": "bun --env-file=.env --watch src/server.tsx",
|
||||||
"prestart": "bun run ./scripts/gen-index.ts",
|
"prestart": "bun run ./scripts/gen-index.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/<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, 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<Pm2App[]> {
|
||||||
|
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<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`);
|
||||||
@@ -31,7 +31,7 @@ import type { DiscoveredPlugin } from './manifest';
|
|||||||
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
|
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
|
||||||
|
|
||||||
/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
|
/** 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.
|
* 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.
|
* Pure, deliberately: `addPluginToEcosystem` below is the read-modify-write that touches the file for a
|
||||||
* Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
|
* 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 {
|
export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
|
||||||
if (!plugin.sidecar) return false;
|
if (!plugin.sidecar) return null;
|
||||||
|
|
||||||
const name = pluginProcessName(plugin.appName);
|
const name = pluginProcessName(plugin.appName);
|
||||||
// Relative to the platform, because `cwd` is pinned to it and an absolute path would break the moment
|
// 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.slice(PLATFORM_DIR.length + 1)
|
||||||
: plugin.sidecar.script;
|
: plugin.sidecar.script;
|
||||||
|
|
||||||
const entry: Pm2App =
|
return plugin.sidecar.runtime === 'node'
|
||||||
plugin.sidecar.runtime === 'node'
|
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
|
||||||
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
|
: { name, script: 'bun', args: `run ${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);
|
apps.push(entry);
|
||||||
writeEcosystemApps(apps);
|
writeEcosystemApps(apps);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user