plugins install, enable, disable and uninstall at runtime

the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:

  /api/example/ping BEFORE install   404
  AFTER install                      200  {"plugin":"example","ok":true}
  AFTER disable                      404
  AFTER enable                       200
  AFTER uninstall                    404
  core route throughout              200

plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.

the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.

mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.

/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.

plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.

not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.

full suite: 719 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:22:18 +00:00
co-authored by Claude Opus 5
parent 0701aba902
commit 282a64a637
11 changed files with 385 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
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';
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
//
// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is
// an administrative act however many members share the server. The capability layer covers it too; this
// is the belt to that braces, the same shape `/api/app-store` uses.
//
// ── This is not the app store ──
//
// The app store installs SIDECARS from a compiled-in catalogue, provisioning containers and asking the
// user questions. This installs PLUGINS from the tree, and asks nothing: put the code there, push the
// schema, mount the routes. The two coexist until the app store is rebuilt on this.
export const pluginsRouter = createRouter();
pluginsRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Plugins are owner-only');
return next();
});
/**
* GET /api/plugins — every plugin in the tree, with what the database knows about each.
*
* Reports `broken` alongside rather than failing: a directory with an unreadable manifest is something to
* show the owner, and refusing the whole list because one plugin is malformed would hide the nine that
* are fine.
*/
pluginsRouter.get('/', async (ctx) => {
const { states, broken } = await snapshotPlugins();
return ctx.json({
plugins: states.map(({ plugin, install, outdated }) => ({
appName: plugin.appName,
prefix: mountPrefix(plugin),
label: plugin.manifest.label,
summary: plugin.manifest.summary,
icon: plugin.manifest.icon,
color: plugin.manifest.color,
publisher: plugin.manifest.publisher,
version: plugin.manifest.version,
platform: plugin.manifest.platform,
permissions: plugin.manifest.permissions,
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
has: {
api: !!plugin.api,
schema: !!plugin.schema,
sidecar: !!plugin.sidecar,
web: !!plugin.web,
},
installed: !!install,
enabled: install?.enabled ?? false,
installedVersion: install?.version ?? null,
outdated,
})),
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.
*/
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 });
});
/**
* 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 });
});
/** 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 });
});
/** 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 });
});