delete a plugin and everything it owns, and stop trusting exit codes

Two halves of the same problem: a plugin installed onto a fresh machine, reported
success at every step, mounted, started its sidecar, registered — and then failed
its first query with 42P01, because `offscale_servers` had never been created.
Nothing said so, and there was no way back to a clean machine to try again.

── The install now asks Postgres ──

`pushSchema` only checked the exit code, and `db:push` is a compound command:
generate the barrel, cd, run drizzle-kit. It can exit 0 having created nothing.
So the install now reads the plugin's schema for the tables it declares and
verifies they exist, failing the install with a message naming what is missing
instead of leaving a stack trace to appear in a sidecar log minutes later.

Tables that are NOT prefixed with the app name are reported as a warning rather
than a failure — they still get created, but `purge` will not remove them and a
reinstall would find them already there.

── `purge`, next to an uninstall that deliberately keeps everything ──

Uninstall is careful on purpose: every table and row survives so a reinstall puts
you back where you were. That is what makes it safe to try, and it left no way to
get to a CLEAN machine. A second install finds the directory (so the catalogue
hides the plugin) and the tables (so push has nothing to do, and a broken schema
stays broken).

So a different verb, a different word, its own confirmation. It reads the table
list from the schema BEFORE deleting the source, uninstalls (stopping the sidecar
— nothing may be running against tables about to vanish), drops, deletes the
directory, then regenerates the barrel unconditionally, since a barrel naming a
directory that no longer exists is what stopped a server booting yesterday.

The table list comes from the plugin's own schema, not from `like '<app>_%'` —
that query eventually drops somebody else's table, because the prefix is a
convention nothing validates and `music` is a prefix of `music-videos`. The
prefix is used as a GUARD instead: a schema module could export `users`, and
anything outside the plugin's own namespace is refused rather than dropped.

── Verified as a full loop on this machine ──

purge offscale → table dropped, directory deleted, barrel regenerated → the
marketplace offers it again → install → cloned, ssh checked, "schema: applied in
2072ms (1 table verified)", dock pinned, mounted, sidecar started.

I left offscale INSTALLED here rather than restoring the half-state it was in
(no install row, table present, sidecar running).

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 09:46:16 +00:00
co-authored by Claude Opus 5
parent 8ad5a5563e
commit 3c03e61c5e
5 changed files with 250 additions and 9 deletions
+8
View File
@@ -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<string, VerbFn>;
+106 -2
View File
@@ -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<PluginActionResult> {
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,
+65
View File
@@ -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 '<appName>_%'`. It is also the one that
// eventually drops somebody else's table: the `<appName>_` 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<string, unknown>;
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<string[]> {
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<void> {
for (const name of names) {
await db.execute(sql.raw(`drop table if exists "${name.replace(/"/g, '""')}" cascade`));
}
}
@@ -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 (
<div className="mt-8 border-t border-duck-dark/10 pt-4">
<button
type="button"
disabled={busy}
onClick={() => setConfirming(true)}
className="cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-red-600 disabled:opacity-40"
>
Delete {plugin.label} and all its data
</button>
</div>
);
}
return (
<div className="mt-8 rounded-lg border border-red-500/30 bg-red-500/5 p-4">
<p className="text-sm font-medium text-duck-dark">Delete {plugin.label} completely?</p>
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs text-duck-dark/60">
<li>
its tables are dropped — every row the plugin stored is gone
{plugin.has?.schema ? '' : ' (this one declares no tables)'}
</li>
<li>
the source directory <code>plugins/{plugin.appName}</code> is deleted
</li>
<li>it stops, unmounts, and its sidecar entry is removed</li>
</ul>
<p className="mt-2 text-xs text-duck-dark/60">
Afterwards it can be installed fresh from the marketplace. This cannot be undone.
</p>
<div className="mt-3 flex gap-2">
<Button tone="danger" disabled={busy} onClick={onPurge}>
Delete everything
</Button>
<Button disabled={busy} onClick={() => setConfirming(false)}>
Cancel
</Button>
</div>
</div>
);
};
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 ? (
<p className="mt-4 text-xs text-duck-dark/40">
Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes
anything the plugin stored.
</p>
) : null}
<p className="mt-4 text-xs text-duck-dark/40">
{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.'}
</p>
<Purge plugin={plugin} busy={busy} onPurge={() => run('purge', plugin.appName)} />
</div>
);
};
@@ -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 };