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:
@@ -0,0 +1,147 @@
|
||||
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).
|
||||
|
||||
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;
|
||||
|
||||
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): 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);
|
||||
steps.push(`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}` };
|
||||
}
|
||||
steps.push('sidecar: started');
|
||||
}
|
||||
|
||||
if (plugin.schema) steps.push('schema: skipped — not wired yet (see install.ts)');
|
||||
|
||||
await recordPluginInstall(appName, plugin.manifest.version);
|
||||
steps.push(`recorded at ${plugin.manifest.version}`);
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
steps.push(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): 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` };
|
||||
steps.push('install record removed');
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
steps.push(`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');
|
||||
}
|
||||
|
||||
steps.push('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> {
|
||||
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');
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
steps.push(`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);
|
||||
}
|
||||
Reference in New Issue
Block a user