Files
platform/src/servers/plugins/install.ts
T
pastilhasandClaude Opus 5 62ee0d1e60 the beat between steps is feedback, not decoration
the comment called it cosmetic and worth being honest about, which reads like an
apology and invites the next reader to delete it as a pointless sleep.

the real reason is better. some of this work is genuinely slow — pm2 start
measures ~770ms — and some is effectively instant. without a pause the fast
steps land in one frame, the log jumps from empty to finished, and you cannot
tell 'it worked' from 'nothing happened'. the interval is what makes a step
something you saw happen rather than something you found already done.

only applied when something is listening, so the json path still runs flat out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:33:53 +00:00

181 lines
8.6 KiB
TypeScript

import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb';
import { PLATFORM_DIR } from '../data-path';
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import type { DiscoveredPlugin } from './manifest';
// The install runner: the four verbs, each as a short ordered list of effects.
//
// ── Order is the whole design ──
//
// Every verb does its work in the order that leaves the system coherent if it stops halfway, because it
// can. Bringing something UP goes outside-in (make it possible, then start it, then record it, then
// expose it); taking something DOWN goes inside-out (stop exposing it, stop it, then forget it). The
// worst intermediate state is then "recorded but not running", which the UI can show and a retry fixes —
// never "running but forgotten", which nothing can see and nothing will clean up.
//
// ── What each verb touches ──
//
// ecosystem PM2 row mounts tables
// install add start upsert rebuild (see below)
// uninstall remove delete delete rebuild untouched
// enable — start enabled=t rebuild untouched
// disable — stop enabled=f rebuild untouched
//
// Nothing here drops a table, ever. Uninstall means "stop running this", and for a plugin holding a
// user's data the two are unrecoverably different — see `plugin_installs` schema.
//
// `[open]` The schema push. A plugin with `db/schema.ts` still needs its tables created, which means
// regenerating the drizzle barrel and running `db:push`. Deliberately not done in the same pass as this:
// 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 — exactly the thing this file refuses to do.
// Offscale does not need it yet (`headscale_servers` already ships in the platform schema).
/**
* Reported as each step completes, for the streaming endpoint.
*
* The runner does not know or care whether anyone is listening — it calls this and carries on, so the
* non-streaming path is the same code with no callback rather than a second implementation.
*/
export type OnStep = (step: string) => void | Promise<void>;
/**
* A beat between steps. Deliberate, and not decoration — do not remove it as a pointless sleep.
*
* Some of this work is genuinely slow (`pm2 start` measures ~770ms) and some is not: writing a row and
* rebuilding the router are effectively instant. Without a pause the fast steps arrive in a single frame,
* so the log jumps from empty to finished and the reader cannot tell "it worked" from "nothing happened".
* The interval IS the feedback — it is what makes each step something you saw occur rather than something
* you found already done.
*
* Small enough that a script does not care, long enough that a person can follow it. Only applied when
* something is listening: `onStep` absent means no beat, so the JSON path runs at full speed.
*/
const STEP_BEAT_MS = 220;
export type PluginActionResult = {
ok: boolean;
appName: string;
/** What actually happened, in order. Returned so the UI can show a real account rather than a spinner. */
steps: string[];
/** Present when a step failed. The plugin is left in the last coherent state above. */
error?: string;
};
/** PM2 is only involved when the plugin actually has a sidecar. Most won't. */
const hasSidecar = (plugin: DiscoveredPlugin) => !!plugin.sidecar;
/** Record a step, tell whoever is listening, and pause so the next one does not land in the same frame. */
async function step(steps: string[], onStep: OnStep | undefined, text: string): Promise<void> {
steps.push(text);
await onStep?.(text);
if (onStep) await Bun.sleep(STEP_BEAT_MS);
}
async function findPlugin(appName: string): Promise<DiscoveredPlugin | null> {
const { states } = await snapshotPlugins();
return states.find((s) => s.plugin.appName === appName)?.plugin ?? null;
}
/**
* Install, or upgrade one already installed.
*
* Idempotent by construction: the ecosystem entry is replaced rather than appended, the row is an upsert,
* and the mount is a rebuild. Re-running after a failure resumes rather than duplicating.
*
* `enabled` is deliberately untouched on the upgrade path — re-installing something the owner had
* switched off must not switch it back on.
*/
export async function installPlugin(appName: string, onStep?: OnStep): Promise<PluginActionResult> {
const plugin = await findPlugin(appName);
if (!plugin) return { ok: false, appName, steps: [], error: `No plugin directory named "${appName}"` };
const steps: string[] = [];
try {
if (hasSidecar(plugin)) {
addPluginToEcosystem(plugin);
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
if (!started.ok) {
// The entry stays. A sidecar that will not start is a plugin to retry or debug, and removing the
// entry would take away the thing that makes `pm2 logs officer-<name>` work.
return { ok: false, appName, steps, error: `sidecar failed to start: ${started.error}` };
}
await step(steps, onStep, 'sidecar: started');
}
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
await recordPluginInstall(appName, plugin.manifest.version);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)');
return { ok: true, appName, steps };
} catch (err) {
return { ok: false, appName, steps, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Uninstall: stop answering, stop running, forget.
*
* Keeps every table and row the plugin owns, and keeps the directory. Reinstalling is therefore a restore
* rather than a fresh start, which is the whole reason not to drop anything here.
*/
export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise<PluginActionResult> {
const steps: string[] = [];
const plugin = await findPlugin(appName);
// Row first: the mount rebuild below reads it, and a failure after this point leaves the plugin
// unmounted and stopped rather than half-visible.
const removed = await removePluginInstall(appName);
if (!removed) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
await step(steps, onStep, 'install record removed');
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `unmounted (now: ${mounted.join(', ') || 'no plugin routes'})`);
if (plugin && hasSidecar(plugin)) {
await stopProcess(pluginProcessName(appName), PLATFORM_DIR);
await deleteProcess(pluginProcessName(appName), PLATFORM_DIR);
removePluginFromEcosystem(appName);
await step(steps, onStep, 'sidecar: stopped, deleted, ecosystem entry removed');
}
await step(steps, onStep, 'tables and data: untouched');
return { ok: true, appName, steps };
}
/** Enable: mount and run again. Disable: the reversible middle — unmount and stop, keep everything. */
export async function setPluginRunning(
appName: string,
enabled: boolean,
onStep?: OnStep,
): Promise<PluginActionResult> {
const steps: string[] = [];
const row = await setPluginEnabled(appName, enabled);
if (!row) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
await step(steps, onStep, enabled ? 'enabled' : 'disabled');
const plugin = await findPlugin(appName);
if (plugin && hasSidecar(plugin)) {
const name = pluginProcessName(appName);
const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR);
await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
}
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
return { ok: true, appName, steps };
}
/** Whether a plugin's sidecar is actually up, for the UI. `null` when it has none or PM2 has not seen it. */
export async function pluginProcessStatus(plugin: DiscoveredPlugin): Promise<string | null> {
if (!hasSidecar(plugin)) return null;
return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR);
}