diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 261304db..cf891c77 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -10,6 +10,7 @@ import { installPlugin, pluginProcessStatus, setPluginRunning, + purgePlugin, uninstallPlugin, type PluginActionResult, } from '../../plugins/install'; @@ -170,6 +171,12 @@ pluginsRouter.post('/:appName/install', async (ctx) => { return ctx.json(result, result.ok ? 200 : 400); }); +// Destructive, and the only route here that is. Named `purge` rather than being a flag on uninstall so +// nothing can reach it by passing a body field to the verb people already use. +pluginsRouter.post('/:appName/purge', async (ctx) => { + return ctx.json(await purgePlugin(ctx.req.param('appName'))); +}); + pluginsRouter.post('/:appName/uninstall', async (ctx) => { const result = await uninstallPlugin(ctx.req.param('appName')); return ctx.json(result, result.ok ? 200 : 400); @@ -205,6 +212,7 @@ const VERBS = { uninstall: (appName, onStep) => uninstallPlugin(appName, onStep), enable: (appName, onStep) => setPluginRunning(appName, true, onStep), disable: (appName, onStep) => setPluginRunning(appName, false, onStep), + purge: (appName, onStep) => purgePlugin(appName, onStep), // `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns // `isVerb` into a guard that proves nothing and lets an unknown verb index the map. } satisfies Record; diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 68717fe9..25a4de3f 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -1,4 +1,12 @@ -import { getDockPaths, recordPluginInstall, removePluginInstall, setDockPaths, setPluginEnabled } from 'officerdb'; +import { + getDockPaths, + getPluginInstall, + recordPluginInstall, + removePluginInstall, + setDockPaths, + setPluginEnabled, +} from 'officerdb'; +import { rm } from 'node:fs/promises'; import { PLATFORM_DIR } from '../data-path'; import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2'; import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem'; @@ -7,6 +15,7 @@ import { fetchPlugin } from './fetch'; import { publishAssets, unpublishAssets } from '../app-store/assets'; import { refreshPluginMounts, snapshotPlugins } from './mount'; import { generatePluginSchemas, pushSchema } from './schema'; +import { declaredTables, dropTables, existingTables } from './tables'; import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps'; import { mountPrefix, type DiscoveredPlugin } from './manifest'; @@ -217,7 +226,38 @@ export async function installPlugin(appName: string, onStep?: OnStep, forUserId? if (!pushed.ok) { return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` }; } - await step(steps, onStep, `schema: applied in ${pushed.ms}ms`); + + // Exit code 0 is not proof the tables exist. + // + // `db:push` is a compound command — generate the barrel, cd, run drizzle-kit — and an install was + // reported as successful on a machine where `offscale_servers` was never created. The plugin then + // mounted, its sidecar started, registered, and failed its first query with 42P01. Every step said + // OK and the plugin was unusable, which is the worst shape a failure can take. + // + // So the install asks Postgres. A missing table fails the install HERE, where the message can say + // what happened, instead of appearing later as a stack trace in a sidecar log. + const { tables, refused } = await declaredTables(plugin); + if (refused.length) { + // Not fatal: the plugin still works, and the tables are still created by push. But they are + // outside its prefix, so `purge` will not remove them and nobody will notice until a reinstall + // finds them already there. + await step(steps, onStep, `schema: WARNING — ${refused.join(', ')} are not prefixed \`${appName}_\``); + } + const present = await existingTables(tables); + const missing = tables.filter((t) => !present.includes(t)); + if (missing.length) { + return { + ok: false, + appName, + steps, + error: `schema push reported success but did not create ${missing.join(', ')}. Run \`bun db:push\` by hand and read its output. Push said: ${pushed.output}`, + }; + } + await step( + steps, + onStep, + `schema: applied in ${pushed.ms}ms${tables.length ? ` (${tables.length} table(s) verified)` : ''}`, + ); } await recordPluginInstall(appName, plugin.manifest.version); @@ -308,6 +348,70 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise return { ok: true, appName, steps }; } +/** + * Delete a plugin completely: its rows, its tables, and its source. + * + * ── Why this exists next to an uninstall that deliberately keeps everything ── + * + * `uninstallPlugin` is careful on purpose. It stops the plugin and forgets it is installed, and every + * table and row survives, because "stop running this" must never mean "delete my data" — a reinstall + * puts you back where you were, which is the property that makes uninstalling safe to try. + * + * That leaves no way to get back to a CLEAN machine. There was no way to test an install twice: the + * second one finds the directory already there, so the catalogue hides it, and finds the tables already + * there, so `db:push` has nothing to do and a broken schema stays broken. This is the verb for that, and + * it is deliberately a different word with a different button and a confirmation. + * + * ── Order, and why this one ── + * + * Tables are read from the schema BEFORE the directory goes, because the list comes from the file that is + * about to be deleted. Then: uninstall (stops the sidecar and unmounts — nothing may be running against + * tables that are about to vanish), drop, delete, regenerate the barrel. + * + * The barrel last and unconditionally: it names plugin directories, and one naming a directory that no + * longer exists is exactly the failure that stopped a server booting on 2026-08-16. Leaving it stale here + * would break the next `db:push` rather than this command. + */ +export async function purgePlugin(appName: string, onStep?: OnStep): Promise { + const steps: string[] = []; + const plugin = await findPlugin(appName); + if (!plugin) return { ok: false, appName, steps, error: `"${appName}" is not on this machine` }; + + // Read the table list while the source still exists. + const { tables, refused } = await declaredTables(plugin).catch(() => ({ tables: [], refused: [] })); + if (refused.length) { + // Said out loud rather than silently skipped: these are the plugin's tables by any reasonable reading + // and they are being LEFT BEHIND, which the next install will trip over. + await step(steps, onStep, `left alone (not prefixed \`${appName}_\`): ${refused.join(', ')}`); + } + + // Stop it first. Dropping a table under a running sidecar gives it a 42P01 instead of a shutdown. + if (await getPluginInstall(appName)) { + const uninstalled = await uninstallPlugin(appName, onStep); + if (!uninstalled.ok) return { ...uninstalled, steps: [...steps, ...uninstalled.steps] }; + steps.push(...uninstalled.steps); + } else { + await step(steps, onStep, 'not installed — nothing to stop'); + } + + if (tables.length) { + const present = await existingTables(tables); + await dropTables(present); + await step(steps, onStep, present.length ? `dropped: ${present.join(', ')}` : 'no tables to drop'); + } + + await rm(plugin.dir, { recursive: true, force: true }); + await step(steps, onStep, `deleted ${plugin.dir}`); + + // From the directories that are left, so the barrel stops naming one that is gone. + const { plugins } = await discoverPlugins(); + generatePluginSchemas(plugins); + await step(steps, onStep, 'schema barrel regenerated'); + + await step(steps, onStep, `"${appName}" can now be installed fresh from the marketplace`); + 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, diff --git a/src/servers/plugins/tables.ts b/src/servers/plugins/tables.ts new file mode 100644 index 00000000..9dfe265c --- /dev/null +++ b/src/servers/plugins/tables.ts @@ -0,0 +1,65 @@ +import { getTableName, is, sql } from 'drizzle-orm'; +import { PgTable } from 'drizzle-orm/pg-core'; +import { db } from 'officerdb/db'; +import type { DiscoveredPlugin } from './manifest'; + +// Which tables a plugin owns, and whether they are actually there. +// +// ── Why this is asked of the SCHEMA rather than of Postgres ── +// +// The obvious implementation is `drop table where name like '_%'`. It is also the one that +// eventually drops somebody else's table: the `_` prefix is a CONVENTION and nothing validates +// it, so a plugin called `music` and a plugin called `music-videos` share a prefix, and a table named by +// hand can match a plugin that never created it. +// +// So the list comes from the plugin's own `db/schema.ts` — the same file `db:push` reads, which makes +// "what this plugin created" and "what this deletes" the same question with the same answer. +// +// The prefix is then used as a GUARD rather than as the query. A schema module is arbitrary TypeScript +// and could export `users`; refusing anything not prefixed means the worst a wrong or hostile plugin can +// do here is fail to clean up after itself. + +/** A plugin's tables, by name, as its schema declares them. Empty for a plugin with no `db/`. */ +export async function declaredTables(plugin: DiscoveredPlugin): Promise<{ tables: string[]; refused: string[] }> { + if (!plugin.schema) return { tables: [], refused: [] }; + + const module = (await import(plugin.schema)) as Record; + const prefix = `${plugin.appName.replace(/-/g, '_')}_`; + + const tables: string[] = []; + const refused: string[] = []; + for (const value of Object.values(module)) { + if (!is(value, PgTable)) continue; + const name = getTableName(value); + // Also accept the literal appName as a table, for a plugin whose single table IS its name. + if (name === plugin.appName || name.startsWith(prefix) || name.startsWith(`${plugin.appName}_`)) tables.push(name); + else refused.push(name); + } + return { tables, refused }; +} + +/** Which of these exist in Postgres right now. */ +export async function existingTables(names: string[]): Promise { + if (names.length === 0) return []; + const rows = await db.execute<{ table_name: string }>( + sql`select table_name from information_schema.tables where table_schema = 'public' and table_name in ${sql.raw( + `(${names.map((n) => `'${n.replace(/'/g, "''")}'`).join(', ')})`, + )}`, + ); + // drizzle's postgres-js driver returns the rows array directly; the pg driver wraps them in `.rows`. + const list = Array.isArray(rows) ? rows : ((rows as { rows?: { table_name: string }[] }).rows ?? []); + return list.map((r) => r.table_name); +} + +/** + * Drop them. `cascade`, because a plugin's tables reference each other and dropping them in dependency + * order is a problem this does not need to solve — everything being dropped belongs to the same plugin. + * + * Names come from `declaredTables`, which has already refused anything outside the plugin's prefix, and + * are quoted here as identifiers. Nothing from a URL or a request body reaches this. + */ +export async function dropTables(names: string[]): Promise { + for (const name of names) { + await db.execute(sql.raw(`drop table if exists "${name.replace(/"/g, '""')}" cascade`)); + } +} diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index f8e0fe40..02171c54 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -1,5 +1,6 @@ import { Link, useSearchParams } from 'react-router'; import { resolveIcon } from '../../utils/resolve-icon'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -276,6 +277,68 @@ const Readme = ({ appName }: { appName: string }) => { ); }; +/** + * Delete a plugin and everything it owns. + * + * Separated from the row of ordinary verbs above by a rule and its own confirmation, because it is the + * only control on this screen that destroys data. Uninstall deliberately keeps every table and row so a + * reinstall puts you back where you were; this is the opposite, and it should not be one slip away from + * the button next to it. + * + * The confirmation is inline rather than a dialog: it states exactly what will go, and it names the + * plugin. A modal that says "Are you sure?" tests whether you can click twice, not whether you know what + * you are about to lose. + * + * Offered whether or not the plugin is installed, because the state that needs it most is the one where + * a broken install has already been uninstalled and the directory is still sitting there hiding the + * plugin from the marketplace. + */ +const Purge = ({ plugin, busy, onPurge }: { plugin: PluginItem; busy: boolean; onPurge: () => void }) => { + const [confirming, setConfirming] = useState(false); + + if (!confirming) { + return ( +
+ +
+ ); + } + + return ( +
+

Delete {plugin.label} completely?

+
    +
  • + its tables are dropped — every row the plugin stored is gone + {plugin.has?.schema ? '' : ' (this one declares no tables)'} +
  • +
  • + the source directory plugins/{plugin.appName} is deleted +
  • +
  • it stops, unmounts, and its sidecar entry is removed
  • +
+

+ Afterwards it can be installed fresh from the marketplace. This cannot be undone. +

+
+ + +
+
+ ); +}; + export const PluginDetail = () => { const { plugins, available, steps, result, running, run } = usePlugins(); const [params] = useSearchParams(); @@ -407,12 +470,13 @@ export const PluginDetail = () => { {/* 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 ? ( -

- Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes - anything the plugin stored. -

- ) : null} +

+ {plugin.installed + ? 'Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes anything the plugin stored.' + : 'Not installed, but the code is still on this machine — which is why the marketplace does not offer it.'} +

+ + run('purge', plugin.appName)} /> ); }; diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 014b2262..2522f39e 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -75,7 +75,7 @@ export type PluginItem = { /** 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 PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable'; +export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge'; export type BrokenPlugin = { appName: string; error: string };