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; /** * 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 { steps.push(text); await onStep?.(text); if (onStep) await Bun.sleep(STEP_BEAT_MS); } async function findPlugin(appName: string): Promise { 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 { 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-` 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 { 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 { 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 { if (!hasSidecar(plugin)) return null; return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR); }