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:
@@ -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