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
+1
View File
@@ -29,6 +29,7 @@ export * as schema from './schema';
export * from './agent-panels';
export * from './api-keys';
export * from './app-store';
export * from './plugins';
export * from './auth';
export * from './capabilities';
export * from './chat-events';
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
@@ -0,0 +1,49 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { pluginInstalls } from './schema';
export type PluginInstall = typeof pluginInstalls.$inferSelect;
/** Every installed plugin, oldest first so the list is stable across renders. */
export async function listPluginInstalls(): Promise<PluginInstall[]> {
return db.select().from(pluginInstalls).orderBy(pluginInstalls.appName);
}
export async function getPluginInstall(appName: string): Promise<PluginInstall | null> {
const [row] = await db.select().from(pluginInstalls).where(eq(pluginInstalls.appName, appName)).limit(1);
return row ?? null;
}
/**
* Record an install, or update the version of one already there.
*
* Upsert rather than insert, because re-installing is how a plugin is upgraded: the code on disk moved,
* and the row should follow it rather than refuse. `enabled` is deliberately NOT touched on the update
* path — re-installing a plugin the owner had disabled must not silently switch it back on.
*/
export async function recordPluginInstall(appName: string, version: string): Promise<PluginInstall> {
const [row] = await db
.insert(pluginInstalls)
.values({ appName, version })
.onConflictDoUpdate({
target: pluginInstalls.appName,
set: { version, updatedAt: new Date() },
})
.returning();
return row!;
}
export async function setPluginEnabled(appName: string, enabled: boolean): Promise<PluginInstall | null> {
const [row] = await db
.update(pluginInstalls)
.set({ enabled, updatedAt: new Date() })
.where(eq(pluginInstalls.appName, appName))
.returning();
return row ?? null;
}
/** Forget the install. Drops no tables and deletes no data — see the note in schema.ts. */
export async function removePluginInstall(appName: string): Promise<boolean> {
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
return rows.length > 0;
}
@@ -0,0 +1,58 @@
import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
// Which plugins are installed on this machine, and whether they should be mounted.
//
// ── Why a row is needed at all, when the code is already on disk ──
//
// Plugins live in the repository (`platform/plugins/<app-name>/`), so PRESENCE is not installation. A
// developer working on a plugin has the directory there and has not installed anything; a plugin that
// ships in a checkout should not mount itself because someone cloned it. The directory answers "what
// could run here", this table answers "what does".
//
// ── Separate from `sidecar_installs`, deliberately ──
//
// That table belongs to the app store's model, where an install 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: put the code there, push its schema, start its sidecar, mount its
// routes. Reusing the table would have meant a `mode` that lies about every plugin. The two models
// coexist until the app store is rebuilt on this one.
//
// ── No userId, for the same reason as `sidecar_installs` ──
//
// installed server-level, owner-only — this row
// permitted per role — role_capabilities
// configured per user — the plugin's own tables
//
// ── `enabled` is not `installed` ──
//
// Installed means the schema is pushed and the code is ready. Enabled means it should be mounted and its
// sidecar running. Disabling is the reversible middle: routes come down, the process stops, and every
// table and row it owns survives untouched. Uninstalling drops the row and unmounts, and still does not
// delete data — dropping a plugin's tables is a separate, deliberate act with the cost shown.
export const pluginInstalls = pgTable(
'plugin_installs',
{
id: serial('id').primaryKey(),
/**
* The plugin's app name — its directory, its route segment and its table prefix, all the same string.
* Text rather than an enum: adding a plugin must never be a schema change.
*/
appName: text('app_name').notNull(),
/**
* The manifest version at the moment it was installed.
*
* Kept so an upgrade has something to compare against, and so "installed" can be told from "installed,
* then the code on disk moved underneath it" — which is the normal state on a developer's machine and
* a thing worth being able to see rather than infer.
*/
version: text('version').notNull(),
enabled: boolean('enabled').notNull().default(true),
installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// One row per plugin, machine-wide. uniqueIndex rather than unique() — see databases/CLAUDE.md on
// drizzle-kit re-creating named composite constraints on every push.
uniqueIndex('uq_plugin_installs_app_name').on(t.appName),
],
);
+1
View File
@@ -38,6 +38,7 @@ export * from './headscale/schema'; // headscale_servers
// The app store itself, and the credentials it stores for what it installs. `app-store/effects.ts`
// reads service_connections, so this is core however few plugins are installed.
export * from './app-store/schema'; // sidecar_installs
export * from './plugins/schema'; // plugin_installs — core: the plugin system is the platform's own
export * from './service-connections/schema'; // service_connections
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────