the install runner: ecosystem entry, sidecar, row, mounts

closes the hole app-store/pm2.ts has carried since 2026-08-13 — "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, and it is why nothing in
the app-store catalogue installs end to end either.

verified against a running server with a sidecar in the tree:

  install    ecosystem added · sidecar started · recorded · mounted /example
             route 200, pm2 online
  disable    sidecar stopped · unmounted
             route 404, pm2 stopped
  enable     sidecar started · mounted
             route 200, pm2 online
  uninstall  record removed · unmounted · sidecar stopped, deleted, entry gone
             route 404, not in pm2, tables untouched

afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.

the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's 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 relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.

order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.

each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.

the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.

720 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 21:38:21 +00:00
co-authored by Claude Opus 5
parent ed195e0904
commit 2634df7a04
7 changed files with 375 additions and 50 deletions
+126
View File
@@ -0,0 +1,126 @@
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. */
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`);
}
/**
* 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 {
if (!plugin.sidecar) return false;
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;
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 };
const apps = readEcosystemApps().filter((a) => a.name !== name);
apps.push(entry);
writeEcosystemApps(apps);
return true;
}
/** 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);
}