stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and the detail panel renders them arriving. POST rather than GET, so EventSource is unavailable — it sends no Authorization header and these routes are owner-only. The client reads the body and parses frames by hand, which is what useCompanionLogStream already does for the headscale container logs; the parser only has to understand what our own endpoint emits. the runner does not know whether anyone is listening. it takes an optional onStep and calls it, so the non-streaming path is the same code with no callback rather than a second implementation of the same four verbs. there is a 220ms beat between steps and it is cosmetic — worth saying out loud. pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the router do not, and four lines landing in one frame look like a stall followed by a jump. small enough not to matter to a script, long enough to follow. writing to a closed stream is caught rather than fatal: navigating away mid-install must not abort the install, because by then it is the server's work and half an install is the one outcome the ordering was designed to avoid. verified over the wire with timestamps — frames arrive incrementally, the sidecar step showing its real duration rather than the beat. afterwards pm2 holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,23 @@ import type { DiscoveredPlugin } from './manifest';
|
||||
// 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, so the log reads rather than blinks.
|
||||
*
|
||||
* Cosmetic, and worth being honest about: `pm2 start` genuinely takes a moment, but writing a row and
|
||||
* rebuilding the router do not, and four lines arriving in the same frame look like a stall followed by a
|
||||
* jump. This is small enough not to matter to a script and long enough for a person to follow.
|
||||
*/
|
||||
const STEP_BEAT_MS = 220;
|
||||
|
||||
export type PluginActionResult = {
|
||||
ok: boolean;
|
||||
appName: string;
|
||||
@@ -44,6 +61,13 @@ export type PluginActionResult = {
|
||||
/** 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;
|
||||
@@ -58,7 +82,7 @@ async function findPlugin(appName: string): Promise<DiscoveredPlugin | null> {
|
||||
* `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): Promise<PluginActionResult> {
|
||||
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}"` };
|
||||
|
||||
@@ -66,7 +90,7 @@ export async function installPlugin(appName: string): Promise<PluginActionResult
|
||||
try {
|
||||
if (hasSidecar(plugin)) {
|
||||
addPluginToEcosystem(plugin);
|
||||
steps.push(`ecosystem: ${pluginProcessName(appName)} added`);
|
||||
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
|
||||
|
||||
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
|
||||
if (!started.ok) {
|
||||
@@ -74,16 +98,16 @@ export async function installPlugin(appName: string): Promise<PluginActionResult
|
||||
// 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}` };
|
||||
}
|
||||
steps.push('sidecar: started');
|
||||
await step(steps, onStep, 'sidecar: started');
|
||||
}
|
||||
|
||||
if (plugin.schema) steps.push('schema: skipped — not wired yet (see install.ts)');
|
||||
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
|
||||
|
||||
await recordPluginInstall(appName, plugin.manifest.version);
|
||||
steps.push(`recorded at ${plugin.manifest.version}`);
|
||||
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
steps.push(mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)');
|
||||
await step(steps, onStep, mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)');
|
||||
|
||||
return { ok: true, appName, steps };
|
||||
} catch (err) {
|
||||
@@ -97,7 +121,7 @@ export async function installPlugin(appName: string): Promise<PluginActionResult
|
||||
* 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): Promise<PluginActionResult> {
|
||||
export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise<PluginActionResult> {
|
||||
const steps: string[] = [];
|
||||
const plugin = await findPlugin(appName);
|
||||
|
||||
@@ -105,38 +129,42 @@ export async function uninstallPlugin(appName: string): Promise<PluginActionResu
|
||||
// unmounted and stopped rather than half-visible.
|
||||
const removed = await removePluginInstall(appName);
|
||||
if (!removed) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
|
||||
steps.push('install record removed');
|
||||
await step(steps, onStep, 'install record removed');
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
steps.push(`unmounted (now: ${mounted.join(', ') || 'no plugin routes'})`);
|
||||
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);
|
||||
steps.push('sidecar: stopped, deleted, ecosystem entry removed');
|
||||
await step(steps, onStep, 'sidecar: stopped, deleted, ecosystem entry removed');
|
||||
}
|
||||
|
||||
steps.push('tables and data: untouched');
|
||||
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): Promise<PluginActionResult> {
|
||||
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` };
|
||||
steps.push(enabled ? 'enabled' : 'disabled');
|
||||
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);
|
||||
steps.push(result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
|
||||
await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
|
||||
}
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
steps.push(`mounts: ${mounted.join(', ') || 'no plugin routes'}`);
|
||||
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user