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:
@@ -0,0 +1,100 @@
|
||||
import { listPluginInstalls, type PluginInstall } from 'officerdb';
|
||||
import type { MountedPlugin } from '../hono';
|
||||
import { rebuildHonoApp } from '../hono';
|
||||
import { discoverPlugins } from './discover';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
|
||||
// Turning what is on disk plus what is in the database into a mounted application.
|
||||
//
|
||||
// Three states, and they are genuinely different questions:
|
||||
//
|
||||
// on disk the directory exists — `discoverPlugins`
|
||||
// installed a `plugin_installs` row — the owner asked for it
|
||||
// enabled that row says so — and has not since turned it off
|
||||
//
|
||||
// Only the third mounts. A plugin a developer is writing sits in the tree unmounted; a disabled plugin
|
||||
// keeps every table and row it owns and simply stops answering.
|
||||
|
||||
/** A plugin, with whatever the database knows about it. `install` is null when nobody has installed it. */
|
||||
export type PluginState = {
|
||||
plugin: DiscoveredPlugin;
|
||||
install: PluginInstall | null;
|
||||
/** The manifest on disk moved after it was installed — normal while developing, worth being able to see. */
|
||||
outdated: boolean;
|
||||
};
|
||||
|
||||
export type PluginsSnapshot = {
|
||||
states: PluginState[];
|
||||
/** Directories that look like plugins and could not be read. Rendered, never thrown — see `discover.ts`. */
|
||||
broken: { appName: string; error: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* What is on disk, joined to what is installed.
|
||||
*
|
||||
* An install row with no directory is DROPPED rather than reported: it means the code was removed from
|
||||
* the tree while the row stayed, and there is nothing to mount, describe or offer. The row is left in the
|
||||
* database on purpose — deleting it here would turn "somebody moved the checkout" into silent data loss.
|
||||
*/
|
||||
export async function snapshotPlugins(): Promise<PluginsSnapshot> {
|
||||
const [{ plugins, broken }, installs] = await Promise.all([discoverPlugins(), listPluginInstalls()]);
|
||||
const byName = new Map(installs.map((row) => [row.appName, row]));
|
||||
|
||||
const states = plugins.map((plugin) => {
|
||||
const install = byName.get(plugin.appName) ?? null;
|
||||
return { plugin, install, outdated: !!install && install.version !== plugin.manifest.version };
|
||||
});
|
||||
|
||||
return { states, broken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a plugin's backend router.
|
||||
*
|
||||
* `api/router.ts` must export `router`. Anything else — a default export, a factory, a bare Hono — is
|
||||
* refused by name rather than mounted wrong: a plugin whose routes silently do not exist is far harder to
|
||||
* diagnose than one that refuses to install.
|
||||
*/
|
||||
export async function loadPluginRouter(plugin: DiscoveredPlugin): Promise<MountedPlugin | null> {
|
||||
if (!plugin.api) return null;
|
||||
|
||||
const module = (await import(plugin.api)) as { router?: unknown };
|
||||
const router = module.router;
|
||||
if (!router || typeof (router as { fetch?: unknown }).fetch !== 'function') {
|
||||
throw new Error(`${plugin.appName}: api/router.ts must export \`router\` (a Hono router)`);
|
||||
}
|
||||
|
||||
return { prefix: mountPrefix(plugin), router: router as MountedPlugin['router'] };
|
||||
}
|
||||
|
||||
/** The plugins that should be mounted right now: installed, enabled, and carrying an `api/router.ts`. */
|
||||
export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<MountedPlugin[]> {
|
||||
const mounted: MountedPlugin[] = [];
|
||||
for (const { plugin, install } of snapshot.states) {
|
||||
if (!install?.enabled || !plugin.api) continue;
|
||||
try {
|
||||
const entry = await loadPluginRouter(plugin);
|
||||
if (entry) mounted.push(entry);
|
||||
} catch (err) {
|
||||
// One plugin that will not load must not take the other nine down with it, and must not stop the
|
||||
// platform booting. It stays unmounted and says why.
|
||||
console.error(`[plugins] ${plugin.appName} not mounted:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
return mounted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the application from the current state of disk and database.
|
||||
*
|
||||
* This is the whole of install, uninstall, enable and disable as far as ROUTING is concerned — each of
|
||||
* those writes a row and then calls this. Hono cannot add a route to a live app and cannot remove one at
|
||||
* all, so nothing is mutated: a fresh app is built and `honoServer` is reassigned. `server.tsx` serves it
|
||||
* through a closure, which is what makes the reassignment take effect.
|
||||
*/
|
||||
export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken: string[] }> {
|
||||
const snapshot = await snapshotPlugins();
|
||||
const mounted = await mountablePlugins(snapshot);
|
||||
rebuildHonoApp(mounted);
|
||||
return { mounted: mounted.map((m) => m.prefix), broken: snapshot.broken.map((b) => b.appName) };
|
||||
}
|
||||
Reference in New Issue
Block a user