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
@@ -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 };