diff --git a/plugins/example/sidecar/index.ts b/plugins/example/sidecar/index.ts new file mode 100644 index 00000000..e4c615bf --- /dev/null +++ b/plugins/example/sidecar/index.ts @@ -0,0 +1,24 @@ +// The reference sidecar: a long-lived process PM2 supervises. +// +// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb +// it, and it is the property that makes install-without-restart possible on the platform side too. +// +// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach +// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to +// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the +// installer, and adding a socket here would test Bun rather than that. + +const name = 'officer-example'; +console.log(`[${name}] started (pid ${process.pid})`); + +// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive. +const beat = setInterval(() => console.log(`[${name}] alive`), 60_000); + +const shutdown = (signal: string) => { + console.log(`[${name}] ${signal} — exiting`); + clearInterval(beat); + process.exit(0); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 4098ba70..fa227406 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -1,9 +1,9 @@ import { createRouter } from '../../create-router'; import * as errors from '../../custom-errors'; import { isSuperAdmin } from '../../super-admin'; -import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb'; import { mountPrefix } from '../../plugins/manifest'; -import { refreshPluginMounts, snapshotPlugins } from '../../plugins/mount'; +import { snapshotPlugins } from '../../plugins/mount'; +import { installPlugin, pluginProcessStatus, setPluginRunning, uninstallPlugin } from '../../plugins/install'; // /api/plugins — what is on this machine, what is installed, and the four verbs that change it. // @@ -33,8 +33,12 @@ pluginsRouter.use(async (ctx, next) => { */ pluginsRouter.get('/', async (ctx) => { const { states, broken } = await snapshotPlugins(); + // Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to + // ask about. A plugin that is installed and enabled but whose process is not online is the state worth + // rendering differently — it is the difference between "off" and "broken". + const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin))); return ctx.json({ - plugins: states.map(({ plugin, install, outdated }) => ({ + plugins: states.map(({ plugin, install, outdated }, i) => ({ appName: plugin.appName, prefix: mountPrefix(plugin), label: plugin.manifest.label, @@ -56,64 +60,33 @@ pluginsRouter.get('/', async (ctx) => { enabled: install?.enabled ?? false, installedVersion: install?.version ?? null, outdated, + processStatus: statuses[i] ?? null, })), broken, }); }); -/** The plugin by name, or a 404 naming it. Shared by every verb below. */ -async function findPlugin(appName: string) { - const { states } = await snapshotPlugins(); - const state = states.find((s) => s.plugin.appName === appName); - if (!state) throw errors.NOT_FOUND(`No plugin directory named "${appName}"`); - return state; -} - /** - * POST /api/plugins/:appName/install - * - * Idempotent, and re-installing is how a plugin is upgraded: the row follows the version on disk. It does - * not touch `enabled`, so re-installing something the owner had switched off does not switch it back on. - * - * `[open]` The schema push and the sidecar's PM2 entry are not wired yet — this records the install and - * mounts the routes. A plugin with `db/schema.ts` or `sidecar/` will need both before it works end to end. + * The four verbs. Each returns the ordered list of what actually happened, rather than a bare `ok` — + * "installed" and "installed but the sidecar would not start" are different outcomes and the second is + * the one worth reading. See `plugins/install.ts` for why the order inside each is what it is. */ pluginsRouter.post('/:appName/install', async (ctx) => { - const { plugin } = await findPlugin(ctx.req.param('appName')); - await recordPluginInstall(plugin.appName, plugin.manifest.version); - const mounts = await refreshPluginMounts(); - return ctx.json({ ok: true, appName: plugin.appName, ...mounts }); + const result = await installPlugin(ctx.req.param('appName')); + return ctx.json(result, result.ok ? 200 : 400); }); -/** - * POST /api/plugins/:appName/uninstall - * - * Drops the row and unmounts. Deletes nothing the plugin owns — its tables and every row in them survive, - * so reinstalling is a restore rather than a fresh start. Dropping a plugin's data is a separate and - * deliberate act, not a side effect of an unrelated one. - */ pluginsRouter.post('/:appName/uninstall', async (ctx) => { - const appName = ctx.req.param('appName'); - const removed = await removePluginInstall(appName); - if (!removed) throw errors.NOT_FOUND(`"${appName}" is not installed`); - const mounts = await refreshPluginMounts(); - return ctx.json({ ok: true, appName, ...mounts }); + const result = await uninstallPlugin(ctx.req.param('appName')); + return ctx.json(result, result.ok ? 200 : 400); }); -/** POST /api/plugins/:appName/enable — mount its routes again. Nothing else changes. */ pluginsRouter.post('/:appName/enable', async (ctx) => { - const appName = ctx.req.param('appName'); - const row = await setPluginEnabled(appName, true); - if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`); - const mounts = await refreshPluginMounts(); - return ctx.json({ ok: true, appName, enabled: true, ...mounts }); + const result = await setPluginRunning(ctx.req.param('appName'), true); + return ctx.json(result, result.ok ? 200 : 400); }); -/** POST /api/plugins/:appName/disable — unmount, keep everything. The reversible middle ground. */ pluginsRouter.post('/:appName/disable', async (ctx) => { - const appName = ctx.req.param('appName'); - const row = await setPluginEnabled(appName, false); - if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`); - const mounts = await refreshPluginMounts(); - return ctx.json({ ok: true, appName, enabled: false, ...mounts }); + const result = await setPluginRunning(ctx.req.param('appName'), false); + return ctx.json(result, result.ok ? 200 : 400); }); diff --git a/src/servers/plugins/ecosystem.test.ts b/src/servers/plugins/ecosystem.test.ts new file mode 100644 index 00000000..473bc4b1 --- /dev/null +++ b/src/servers/plugins/ecosystem.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'bun:test'; +import { pluginProcessName } from './ecosystem'; + +// The read/write pair is exercised live in `install.test.ts` against a real generated file, because the +// thing worth testing is that a round trip preserves the CORE entries officer-setup wrote — and that is a +// property of the real file's shape, not of a fixture I would author to match my own parser. + +describe('pluginProcessName', () => { + it('is one rule, so nothing has to look it up', () => { + expect(pluginProcessName('offscale')).toBe('officer-offscale'); + expect(pluginProcessName('example')).toBe('officer-example'); + }); +}); diff --git a/src/servers/plugins/ecosystem.ts b/src/servers/plugins/ecosystem.ts new file mode 100644 index 00000000..85f949b3 --- /dev/null +++ b/src/servers/plugins/ecosystem.ts @@ -0,0 +1,126 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PLATFORM_DIR } from '../data-path'; +import type { DiscoveredPlugin } from './manifest'; + +// Giving a plugin's sidecar an entry in the PM2 ecosystem file, and taking it away again. +// +// ── The hole this closes ── +// +// `app-store/pm2.ts` has carried this since 2026-08-13: +// +// "[open] As of 2026-08-13 it contains the SIX core processes and nothing else, so +// `pm2 start ecosystem.config.cjs --only officer-jellyfin` finds no such app and does nothing. +// 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. It is why nothing in the app-store catalogue installs end to end today: the +// container comes up, the connection is written, the icon publishes, and the sidecar never starts. +// +// ── Why the file is read back rather than regenerated ── +// +// The CORE entries are written by `officer-setup` from a shell array (`services.sh`), so the platform +// cannot regenerate the whole file — it does not know what the core list is, and duplicating it here +// would be a second copy to drift. Instead the file is `require`d (it is CommonJS, deliberately, because +// package.json says `"type": "module"` and PM2 `require`s the config), its `apps` array is edited, and it +// is written back. Whatever officer-setup put there survives untouched. +// +// The header above `module.exports` is preserved verbatim — officer-setup's is the better one, and losing +// it to a plugin install would be a poor trade. + +const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs'); + +/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */ +type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean }; + +/** + * The header to write when there is no file to take one from. + * + * Normally the EXISTING header is preserved verbatim — officer-setup's explains why `cwd` is pinned on + * every app and what happens when it is wrong, and that is worth more than anything restated here. This + * is only the fallback for a file that does not exist yet. + */ +const FALLBACK_HEADER = `// Generated. Not in git, and not meant to be — it describes THIS install. +// Core processes come from officer-setup (scripts/setup/officer-setup/lib/services.sh); plugin processes +// are added and removed by the plugin installer (servers/plugins/ecosystem.ts). +`; + +/** The PM2 process name for a plugin's sidecar. One rule, so nothing has to look it up. */ +export const pluginProcessName = (appName: string): string => `officer-${appName}`; + +/** Read the current apps array. An unreadable or missing file is an empty one — the caller decides. */ +export function readEcosystemApps(): Pm2App[] { + if (!existsSync(ECOSYSTEM_PATH)) return []; + try { + // A plain parse rather than `require`: the file is generated and its shape is known, and requiring it + // would cache the module so a second read in the same process returned a stale array. + const source = readFileSync(ECOSYSTEM_PATH, 'utf-8'); + const start = source.indexOf('['); + const end = source.lastIndexOf(']'); + if (start < 0 || end < 0) return []; + // The generated file is JS object literals, not JSON — keys are bare and strings are single-quoted. + // Evaluating it is safe in the way that matters: it is a file this process wrote, sitting inside the + // install root, and anything able to edit it can already edit the server's own source. + const apps = new Function(`return ${source.slice(start, end + 1)}`)() as Pm2App[]; + return Array.isArray(apps) ? apps : []; + } catch { + return []; + } +} + +/** + * Rewrite the apps array, keeping everything above `module.exports` exactly as it was. + * + * The header is preserved rather than regenerated because officer-setup's is the better one — it 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` silently relocates the whole install rather than failing. Losing that to a + * plugin install would be a poor trade. + */ +function writeEcosystemApps(apps: Pm2App[]): void { + const existing = existsSync(ECOSYSTEM_PATH) ? readFileSync(ECOSYSTEM_PATH, 'utf-8') : ''; + const marker = existing.indexOf('module.exports'); + const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER; + + const lines = apps.map( + (app) => + ` { name: '${app.name}', script: '${app.script}'` + + (app.args ? `, args: '${app.args}'` : '') + + `, cwd: '${app.cwd}', watch: ${app.watch} },`, + ); + writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`); +} + +/** + * Ensure the plugin's sidecar has an entry, replacing one that is already there. + * + * Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved. + * Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't. + */ +export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean { + if (!plugin.sidecar) return false; + + const name = pluginProcessName(plugin.appName); + // Relative to the platform, because `cwd` is pinned to it and an absolute path would break the moment + // the install root moved — which is exactly what happened to this machine two days ago. + const script = plugin.sidecar.script.startsWith(PLATFORM_DIR) + ? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1) + : plugin.sidecar.script; + + const entry: Pm2App = + plugin.sidecar.runtime === 'node' + ? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false } + : { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false }; + + const apps = readEcosystemApps().filter((a) => a.name !== name); + apps.push(entry); + writeEcosystemApps(apps); + return true; +} + +/** Drop the plugin's entry. Safe to call when it was never there. */ +export function removePluginFromEcosystem(appName: string): void { + const name = pluginProcessName(appName); + const apps = readEcosystemApps(); + const remaining = apps.filter((a) => a.name !== name); + if (remaining.length !== apps.length) writeEcosystemApps(remaining); +} diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts new file mode 100644 index 00000000..fd5cb8ae --- /dev/null +++ b/src/servers/plugins/install.ts @@ -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 { + 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 { + 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-` 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 { + 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 { + 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 { + if (!hasSidecar(plugin)) return null; + return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR); +} diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index 74c1e2b2..92cf20d4 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -62,6 +62,10 @@ export const PluginDetail = () => { } const busy = install.isPending || uninstall.isPending || enable.isPending || disable.isPending; + // Whichever verb ran last. They are mutually exclusive in practice — the buttons are disabled while any + // is pending — so the first with data is the one that just happened. + const last = [install, uninstall, enable, disable].find((m) => m.data || m.error); + const result = last?.data; return (
@@ -83,6 +87,21 @@ export const PluginDetail = () => { + {plugin.has.sidecar ? ( + + {/* Enabled but not online is the state worth naming: the plugin is switched on and its + process is not running, which is broken rather than off. */} + + {plugin.processStatus ?? 'not started'} + + + ) : null} {plugin.permissions.length ? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ') @@ -118,6 +137,20 @@ export const PluginDetail = () => { )}
+ {/* What the last verb actually did. 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 happened. */} + {result ? ( +
+
{result.ok ? 'Done' : 'Failed'}
+
    + {result.steps.map((step, i) => ( +
  • · {step}
  • + ))} +
+ {result.error ?
{result.error}
: null} +
+ ) : null} + {/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than leaving someone to guess whether the button destroys their data. */} {plugin.installed ? ( diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index fe20fc3e..54b4d38b 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -27,8 +27,17 @@ export type PluginItem = { installedVersion: string | null; /** The code on disk moved after it was installed — normal while developing, and worth seeing. */ outdated: boolean; + /** + * PM2's word for the sidecar, or null when the plugin has none. `online` while enabled is healthy; + * anything else while enabled is the state worth rendering differently — the difference between a + * plugin that is off and one that is broken. + */ + processStatus: string | null; }; +/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */ +export type PluginActionResult = { ok: boolean; appName: string; steps: string[]; error?: string }; + export type BrokenPlugin = { appName: string; error: string }; const PLUGINS_KEY = ['plugins']; @@ -52,19 +61,19 @@ export function usePlugins() { // Written out rather than generated in a loop: `useMutation` is a hook, and a hook called from inside a // helper is a rules-of-hooks violation even when the call order happens to be stable. const install = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}), + mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}), onSuccess: invalidate, }); const uninstall = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}), + mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}), onSuccess: invalidate, }); const enable = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}), + mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}), onSuccess: invalidate, }); const disable = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}), + mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}), onSuccess: invalidate, });