import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { PLATFORM_DIR } from '../data-path'; import type { DiscoveredPlugin } from './manifest'; // Giving a plugin's sidecar an entry in the PM2 ecosystem file, and taking it away again. // // ── The hole this closes ── // // `app-store/pm2.ts` has carried this since 2026-08-13: // // "[open] As of 2026-08-13 it contains the SIX core processes and nothing else, so // `pm2 start ecosystem.config.cjs --only officer-jellyfin` finds no such app and does nothing. // Installing a plugin has to append its entry here before starting it — that is the plugin // system's job and it is not built." // // This is that job. It is why nothing in the app-store catalogue installs end to end today: the // container comes up, the connection is written, the icon publishes, and the sidecar never starts. // // ── Why the file is read back rather than regenerated ── // // The CORE entries are written by `officer-setup` from a shell array (`services.sh`), so the platform // cannot regenerate the whole file — it does not know what the core list is, and duplicating it here // would be a second copy to drift. Instead the file is `require`d (it is CommonJS, deliberately, because // package.json says `"type": "module"` and PM2 `require`s the config), its `apps` array is edited, and it // is written back. Whatever officer-setup put there survives untouched. // // The header above `module.exports` is preserved verbatim — officer-setup's is the better one, and losing // it to a plugin install would be a poor trade. const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs'); /** One PM2 app entry. Matches what `services.sh` emits for the core processes. */ 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. * * Normally the EXISTING header is preserved verbatim — officer-setup's explains why `cwd` is pinned on * every app and what happens when it is wrong, and that is worth more than anything restated here. This * is only the fallback for a file that does not exist yet. */ const FALLBACK_HEADER = `// Generated. Not in git, and not meant to be — it describes THIS install. // Core processes come from officer-setup (scripts/setup/officer-setup/lib/services.sh); plugin processes // are added and removed by the plugin installer (servers/plugins/ecosystem.ts). `; /** The PM2 process name for a plugin's sidecar. One rule, so nothing has to look it up. */ export const pluginProcessName = (appName: string): string => `officer-${appName}`; /** Read the current apps array. An unreadable or missing file is an empty one — the caller decides. */ export function readEcosystemApps(): Pm2App[] { if (!existsSync(ECOSYSTEM_PATH)) return []; try { // A plain parse rather than `require`: the file is generated and its shape is known, and requiring it // would cache the module so a second read in the same process returned a stale array. const source = readFileSync(ECOSYSTEM_PATH, 'utf-8'); const start = source.indexOf('['); const end = source.lastIndexOf(']'); if (start < 0 || end < 0) return []; // The generated file is JS object literals, not JSON — keys are bare and strings are single-quoted. // Evaluating it is safe in the way that matters: it is a file this process wrote, sitting inside the // install root, and anything able to edit it can already edit the server's own source. const apps = new Function(`return ${source.slice(start, end + 1)}`)() as Pm2App[]; return Array.isArray(apps) ? apps : []; } catch { return []; } } /** * Rewrite the apps array, keeping everything above `module.exports` exactly as it was. * * The header is preserved rather than regenerated because officer-setup's is the better one — it explains * that Bun auto-loads .env from the working directory, and that data-path derives the install root from * its PARENT, so a wrong `cwd` silently relocates the whole install rather than failing. Losing that to a * plugin install would be a poor trade. */ function writeEcosystemApps(apps: Pm2App[]): void { const existing = existsSync(ECOSYSTEM_PATH) ? readFileSync(ECOSYSTEM_PATH, 'utf-8') : ''; const marker = existing.indexOf('module.exports'); const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER; const lines = apps.map( (app) => ` { name: '${app.name}', script: '${app.script}'` + (app.args ? `, args: '${app.args}'` : '') + `, cwd: '${app.cwd}', watch: ${app.watch} },`, ); writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`); } /** * The PM2 entry a plugin's sidecar would get, or null when it has none — 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 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 // the install root moved — which is exactly what happened to this machine two days ago. const script = plugin.sidecar.script.startsWith(PLATFORM_DIR) ? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1) : plugin.sidecar.script; 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 }; } /** * 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; } /** * Add a CORE process the running platform knows it needs, when the file predates it. * * Normally core entries come from `officer-setup`'s CORE_PROCESSES and the platform never writes one — * it does not know the core list and duplicating it here would be a second copy to drift. This is the * exception, and it is narrow on purpose: an existing install that pulls a commit which MOVED something * into the core set has no other way to learn about it, because `git pull` changes nothing outside the * repository. Caller supplies the whole entry; nothing here has an opinion about what belongs. * * Idempotent. Replaces an entry of the same name so a changed script path is picked up. */ export function addCoreProcess(entry: Pm2App): void { const apps = readEcosystemApps().filter((a) => a.name !== entry.name); apps.push(entry); writeEcosystemApps(apps); } /** Drop the plugin's entry. Safe to call when it was never there. */ export function removePluginFromEcosystem(appName: string): void { const name = pluginProcessName(appName); const apps = readEcosystemApps(); const remaining = apps.filter((a) => a.name !== name); if (remaining.length !== apps.length) writeEcosystemApps(remaining); }