Files
platform/src/servers/plugins/ecosystem.ts
T
pastilhasandClaude Opus 5 d6f093532d install radicale and register officer-caldav on an existing server's next boot
Calendar and contacts became core in 9c309c5a. A FRESH install gets radicale
from machine-setup and officer-caldav from CORE_PROCESSES; an existing one gets
neither from a git pull, because a pull changes nothing outside the repository.
So without this, pulling that commit leaves /calendar mounted, rendering, and
talking to nothing — the silent half-working failure this codebase keeps
getting bitten by, and the same shape as the plugin permission collision fixed
in ee321f49.

Best-effort and AFTER serve(), never blocking: a held apt lock, an unreachable
mirror or a machine without passwordless sudo must not stop Officer starting.
Uses the existing os-deps machinery rather than a second spelling of "install a
package" — including `sudo -n`, which fails fast instead of hanging on a prompt
no one can answer under PM2. When it cannot install, it logs the exact command
to run by hand.

addCoreProcess is new and deliberately narrow. The platform normally never
writes a core entry — it does not know the core list, and duplicating
CORE_PROCESSES here would be a second copy to drift. The exception is exactly
this: a commit that MOVES something into the core set, which an existing
install has no other way to learn about.

Verified on this machine rather than assumed: radicale was absent, the guard
installed it (3.1.8), and a second run reported `present` and wrote nothing.
The install path is not unit-tested on purpose — a test that really ran
apt-get would modify the machine running it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:28:17 +00:00

156 lines
7.6 KiB
TypeScript

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);
}