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:
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user