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>
181 lines
8.6 KiB
TypeScript
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);
|
|
}
|