plugins: enable, disable, delete — and uninstall is gone

Three states all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-still-
there. Nothing on screen distinguished them, and the one that mattered was invisible — an uninstalled
plugin kept its directory, which silently decided what the next install would run. That is how a
jellyfin install on alpha re-applied a checkout from two days earlier while reporting success at
every step.

So uninstall stops being a verb. What it did survives as the first half of deleting: it stops the
sidecar, removes the install row and the PM2 entry, unpublishes the copies, and revokes grants naming
permissions that are about to stop existing.

Delete is offered only on a plugin that is not running — disable is one reversible click, so a live
plugin is always two deliberate steps from gone. It opens a confirmation carrying a checkbox, "also
delete all its data", unticked and never inherited from last time. Unticked, the directory goes and
the rows stay, and they appear in the orphan list further down the same screen. Ticked, the tables go
with it, which is the only way to a machine that has never had the plugin.

The checkbox picks between two ROUTES, not a body flag on one: a flag can be added to a request by
anything that already knows how to call the safe verb, and the difference here is somebody's rows.

Grants are revoked either way, which is load-bearing until the permissions work tomorrow: a grant
naming a key the platform no longer recognises made the whole permissions screen unsaveable, because
the page submits every grant it loaded.

Also corrected two rationales that the change made false — the runbook's "uninstall never destroys
data" verification table, and npm-deps.ts explaining directory survival in terms of uninstall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:19:32 +00:00
co-authored by Claude Opus 5
parent d6aef4cb92
commit 287cec39d0
7 changed files with 217 additions and 119 deletions
+16 -11
View File
@@ -12,7 +12,6 @@ import {
pluginProcessStatus,
setPluginRunning,
purgePlugin,
uninstallPlugin,
type PluginActionResult,
} from '../../plugins/install';
import { updatePlugin, syncCapabilities } from '../../plugins/update';
@@ -218,15 +217,20 @@ 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')));
// ── Two doors, because "delete" means two things ──
//
// `delete` takes the plugin off the machine and leaves its tables. `purge` takes the data as well. Two
// routes rather than one route and a `dropData` body field: a flag can be added to a request by anything
// that already knows how to call the safe verb, and the difference here is somebody's rows.
//
// The UI's checkbox chooses which one it calls, so the confirmation the owner reads and the door that
// opens are the same decision.
pluginsRouter.post('/:appName/delete', async (ctx) => {
return ctx.json(await purgePlugin(ctx.req.param('appName'), { dropData: false }));
});
pluginsRouter.post('/:appName/uninstall', async (ctx) => {
const result = await uninstallPlugin(ctx.req.param('appName'));
return ctx.json(result, result.ok ? 200 : 400);
pluginsRouter.post('/:appName/purge', async (ctx) => {
return ctx.json(await purgePlugin(ctx.req.param('appName'), { dropData: true }));
});
pluginsRouter.post('/:appName/enable', async (ctx) => {
@@ -239,7 +243,7 @@ pluginsRouter.post('/:appName/disable', async (ctx) => {
return ctx.json(result, result.ok ? 200 : 400);
});
// ── The same four verbs, streamed ──
// ── The same verbs, streamed ──
//
// An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that
// and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could
@@ -261,10 +265,11 @@ type VerbFn = (
const VERBS = {
install: (appName, onStep, userId, onLog) => installPlugin(appName, onStep, userId, onLog),
uninstall: (appName, onStep, _userId, onLog) => uninstallPlugin(appName, onStep, onLog),
enable: (appName, onStep) => setPluginRunning(appName, true, onStep),
disable: (appName, onStep) => setPluginRunning(appName, false, onStep),
purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, onStep, onLog),
// Same function, and the verb IS the flag — see the two routes above.
delete: (appName, onStep, _userId, onLog) => purgePlugin(appName, { dropData: false }, onStep, onLog),
purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, { dropData: true }, onStep, onLog),
// A pull and then the ordinary install path — see plugins/update.ts. It is a verb rather than its own
// route so it inherits the streaming door and the UI that already drives install.
update: (appName, onStep, userId, onLog) => updatePlugin(appName, onStep, userId, onLog),
+50 -15
View File
@@ -472,10 +472,18 @@ export async function installPlugin(
}
/**
* Uninstall: stop answering, stop running, forget.
* Stop answering, stop running, forget — the first half of deleting a plugin.
*
* Keeps every table and row the plugin owns, and keeps the directory. Reinstalling is therefore a restore
* rather than a fresh start, which is the whole reason not to drop anything here.
* NOT a verb any more. Until 2026-08-21 this was its own button beside Disable, and the screen offered
* three states that all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-
* still-there. The difference that mattered was invisible — an uninstalled plugin stayed in the list
* under a heading saying "Installed", so a successful uninstall looked like a failed one, and the
* directory it left behind silently decided what the next install would run.
*
* So it lives on as a step: `purgePlugin` calls it before dropping anything, because a sidecar must not
* be running against tables that are about to vanish. Keeps every table, row and file — what it removes
* is the PLATFORM's record of the plugin: the install row, the PM2 entry, the published copies, and the
* grants naming permissions that are about to stop existing.
*/
export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise<PluginActionResult> {
const steps: string[] = [];
@@ -529,16 +537,21 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
/**
* Delete a plugin completely: its rows, its tables, and its source.
*
* ── Why this exists next to an uninstall that deliberately keeps everything ──
* ── One function, two verbs, and the checkbox that chooses ──
*
* `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.
* `dropData` is the difference between the two things "delete" can mean, and they are not close enough to
* guess at. WITHOUT it the plugin leaves the machine and its tables stay: the platform stops offering it,
* the directory goes, and reinstalling later finds the rows where they were. WITH it the tables go too,
* which is the only way back to a machine that has never seen the plugin — the state an install needs to
* be testable twice, because the second install finds the directory gone AND the schema unapplied.
*
* 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.
* It is an argument rather than a body field on one route: `POST /:appName/delete` and
* `POST /:appName/purge` are separate doors, so nothing reaches the destructive one by adding a field to
* the request people already send. The UI asks with a checkbox and picks the door.
*
* Uninstall used to sit between these as its own verb. It was removed on 2026-08-21 — three states that
* all looked stopped, and the difference between two of them was invisible on screen. What it did is
* still here, as a step inside this rather than a button of its own.
*
* ── Order, and why this one ──
*
@@ -550,7 +563,17 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
* 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, onLog?: OnLog): Promise<PluginActionResult> {
export type PurgeOptions = {
/** Drop the plugin's tables. False leaves every row where it is — see the note above. */
dropData: boolean;
};
export async function purgePlugin(
appName: string,
{ dropData }: PurgeOptions,
onStep?: OnStep,
onLog?: OnLog,
): Promise<PluginActionResult> {
const steps: string[] = [];
const plugin = await findPlugin(appName);
if (!plugin) return { ok: false, appName, steps, error: `"${appName}" is not on this machine` };
@@ -574,8 +597,14 @@ export async function purgePlugin(appName: string, onStep?: OnStep, onLog?: OnLo
if (tables.length) {
const present = await existingTables(tables);
await dropTables(present);
await step(steps, onStep, present.length ? `dropped: ${present.join(', ')}` : 'no tables to drop');
if (dropData) {
await dropTables(present);
await step(steps, onStep, present.length ? `dropped: ${present.join(', ')}` : 'no tables to drop');
} else if (present.length) {
// Named, not summarised. These outlive the plugin by request, and they will show up in the orphan
// scan on this same screen a moment from now — saying which ones here is what connects the two.
await step(steps, onStep, `data kept: ${present.join(', ')}`);
}
}
await rm(plugin.dir, { recursive: true, force: true });
@@ -586,7 +615,13 @@ export async function purgePlugin(appName: string, onStep?: OnStep, onLog?: OnLo
generatePluginSchemas(plugins);
await step(steps, onStep, 'schema barrel regenerated');
await step(steps, onStep, `"${appName}" can now be installed fresh from the marketplace`);
await step(
steps,
onStep,
dropData
? `"${appName}" can now be installed fresh from the marketplace`
: `"${appName}" can be installed again from the marketplace — its data is still here`,
);
return { ok: true, appName, steps };
}
+5 -5
View File
@@ -154,8 +154,8 @@ export const anyPluginWantsPackages = (plugins: DiscoveredPlugin[]): boolean =>
// wired. Removed rather than left dangling — an export with no caller is an unfinished thought pretending
// to be a feature.
//
// The behaviour it would have served is right anyway, for a reason worth stating. Uninstall keeps the
// plugin's DIRECTORY (see the runbook), so `discoverPlugins` still finds it and its packages are still
// declared. Only "Delete … and all its data" removes the directory, and after that the next install of
// any plugin regenerates the merged file without it. So the packages linger until the next install and
// then go, with nothing needing to remember them.
// The behaviour it would have served is right anyway, for a reason worth stating. Disabling a plugin keeps
// its DIRECTORY, so `discoverPlugins` still finds it and its packages are still declared — a disabled
// plugin that is re-enabled needs them, and nothing had to remember that. Deleting removes the directory,
// and the next install of any plugin regenerates the merged file without it. So the packages linger until
// then and go on their own.
@@ -5,6 +5,8 @@ import { useQuery } from '@tanstack/react-query';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useClient } from 'hooks/useClient';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
usePlugins,
type AvailablePlugin,
@@ -15,7 +17,7 @@ import {
type PluginVerb,
} from './usePlugins';
// The right panel: one plugin, and the four verbs.
// The right panel: one plugin, and its verbs — install, enable, disable, update, delete.
//
// Reads `?selected=` itself rather than being handed a plugin by the list — neither panel tells the other
// anything, so they cannot disagree.
@@ -284,72 +286,109 @@ const Readme = ({ appName }: { appName: string }) => {
};
/**
* Delete a plugin and everything it owns.
* Delete a plugin: take it off the machine, and choose whether its data goes too.
*
* 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.
* ── Why a checkbox and not two buttons ──
*
* 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.
* "Delete" is one intention with two scopes, and which one somebody wants depends on why they are
* deleting. Moving a plugin aside to reinstall it means keeping the rows; getting a machine back to
* never-having-had-it means dropping them. Two buttons would make that a choice between two words that
* sound identical; a checkbox inside the confirmation makes it a decision taken while reading what each
* one does, which is the only moment it can be made honestly.
*
* ── Only once it is uninstalled ──
* Unticked by default, and it stays unticked — the destructive scope is never the one you get by
* clicking through. What it will drop is named on the line beside it rather than described, because
* "all its data" means nothing until you can see it says `jellyfin_servers`.
*
* This used to be offered in every state, including while the plugin was running. Two clicks — one of
* them a confirmation nobody reads the second time — separated a working plugin from its tables being
* dropped. Now deleting is the SECOND of two verbs: uninstall stops it and forgets it, and only then can
* it be deleted. That is also the order the buttons appear in, so the screen teaches the sequence.
* ── Only when it is not running ──
*
* Nothing is lost by the gate. The state this exists for — a directory sitting on disk keeping the plugin
* out of the marketplace — is by definition an uninstalled one.
* Offered on a disabled or not-installed plugin, never a live one. Disable is a single click and it is
* reversible; requiring it first means a running plugin is always two deliberate steps from being gone,
* with the confirmation as the second. It also matches what the code does — `purgePlugin` stops the
* sidecar before dropping anything, because dropping a table under a running process gives it a 42P01
* rather than a shutdown.
*/
const Purge = ({ plugin, busy, onPurge }: { plugin: PluginItem; busy: boolean; onPurge: () => void }) => {
const [confirming, setConfirming] = useState(false);
const DeletePlugin = ({
plugin,
busy,
onDelete,
}: {
plugin: PluginItem;
busy: boolean;
onDelete: (dropData: boolean) => void;
}) => {
const [open, setOpen] = useState(false);
const [dropData, setDropData] = useState(false);
// Installed means uninstall first. The button is absent rather than disabled: a disabled control invites
// a hunt for what would enable it, and the answer here is a different button three inches away.
if (plugin.installed) return null;
if (plugin.enabled) return null;
if (!confirming) {
return (
<div className="mt-8 border-t border-duck-dark/10 pt-4">
<Button tone="danger" disabled={busy} onClick={() => setConfirming(true)}>
Delete {plugin.label}
</Button>
<p className="mt-2 text-xs text-duck-dark/40">
Removes its tables, its data and <code>plugins/{plugin.appName}</code> — and puts it back in the marketplace.
</p>
</div>
);
}
const hasTables = !!plugin.has?.schema;
return (
<div className="mt-8 rounded-lg border border-destructive/30 bg-destructive/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>
<>
<Button
tone="danger"
disabled={busy}
onClick={() => {
setDropData(false); // never inherited from the last time this was opened
setOpen(true);
}}
>
Delete
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete {plugin.label}?</DialogTitle>
</DialogHeader>
<ul className="list-disc space-y-0.5 pl-5 text-xs text-duck-dark/60">
<li>it stops, unmounts, and its sidecar entry is removed</li>
<li>
the source directory <code>plugins/{plugin.appName}</code> is deleted
</li>
<li>its permission grants are removed — reinstalling means granting again</li>
</ul>
{hasTables ? (
<label className="mt-1 flex cursor-pointer items-start gap-2.5 rounded-md border border-destructive/30 bg-destructive/5 p-3">
<Checkbox
checked={dropData}
onCheckedChange={(v) => setDropData(v === true)}
disabled={busy}
className="mt-0.5"
/>
<span className="text-xs text-duck-dark">
<span className="font-medium">Also delete all its data</span>
<span className="mt-0.5 block text-duck-dark/60">
Drops every table this plugin owns. Without this its rows stay, and reinstalling later finds them
where they were.
</span>
</span>
</label>
) : (
<p className="text-xs text-duck-dark/50">This plugin declares no tables, so there is no data to keep.</p>
)}
<DialogFooter className="mt-2">
<Button disabled={busy} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
tone="danger"
disabled={busy}
onClick={() => {
setOpen(false);
onDelete(dropData);
}}
>
{dropData ? 'Delete it and its data' : `Delete ${plugin.label}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -457,7 +496,15 @@ export const PluginDetail = () => {
>
Install
</Button>
) : (
) : null}
{!plugin.installed ? (
<DeletePlugin
plugin={plugin}
busy={busy}
onDelete={(dropData) => run(dropData ? 'purge' : 'delete', plugin.appName)}
/>
) : null}
{plugin.installed ? (
<>
{plugin.enabled ? (
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
@@ -476,24 +523,26 @@ export const PluginDetail = () => {
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
{plugin.outdated ? `Update to ${plugin.version}` : 'Update'}
</Button>
<Button tone="danger" disabled={busy} onClick={() => run('uninstall', plugin.appName)}>
Uninstall
</Button>
{/* Absent while enabled — disable first. `DeletePlugin` decides that itself rather than being
conditioned here, so the rule lives next to the confirmation that explains it. */}
<DeletePlugin
plugin={plugin}
busy={busy}
onDelete={(dropData) => run(dropData ? 'purge' : 'delete', plugin.appName)}
/>
</>
)}
) : null}
</div>
<ActionLog steps={steps} result={result} running={running} />
{/* 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. */}
{/* Disable and delete are different in kind, not in degree, so the screen says which is which
rather than leaving someone to infer it from two buttons sitting next to each other. */}
<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. Installing again reuses this directory as it stands; deleting it below gets you a fresh clone.'}
? 'Disabling unmounts its routes and stops its sidecar, and changes nothing else — it is reversible in one click. Deleting needs it disabled first.'
: 'Not installed, but the code is still on this machine — which is why the marketplace does not offer it. Installing again reuses this directory as it stands; deleting gets you a fresh clone.'}
</p>
<Purge plugin={plugin} busy={busy} onPurge={() => run('purge', plugin.appName)} />
</div>
);
};
@@ -160,7 +160,7 @@ export const PluginsList = () => {
* Tables in the database that no code on this machine claims.
*
* Deliberately at the BOTTOM and deliberately quiet. It is not an error — a plugin's tables outlive it on
* purpose, so an uninstalled plugin that is still on disk owns its own — and most machines will never show
* purpose, so a plugin deleted without its data leaves its tables behind — and most machines will never show
* this at all.
*
* It exists because the last time these were present nothing said so. `bun db:push` saw tables the schema
@@ -195,8 +195,8 @@ const Orphans = ({ orphans }: { orphans: OrphanTable[] }) => {
))}
</ul>
<p className="mt-1.5 text-[11px] text-duck-dark/35">
Kept, not deleted — uninstalling a plugin never drops its data. Install the plugin to adopt them, or drop
them yourself if they are from something gone for good.
Kept, not deleted — a plugin deleted without ticking "also delete all its data" leaves these behind. Install
the plugin to adopt them, or drop them yourself if they are from something gone for good.
</p>
</div>
</div>
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient, getHeaders } from 'hooks/useClient';
// Reading and driving the plugin system. One query, four verbs.
// Reading and driving the plugin system. One query, and the verbs that change it.
//
// Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and
// asking questions; this installs plugins from the tree and asks nothing.
@@ -84,7 +84,8 @@ export type UpdateAllResult = {
failed: { appName: string; error?: string }[];
};
export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge' | 'update';
// `delete` and `purge` are the same action at two scopes — see the two routes in api/plugins/router.ts.
export type PluginVerb = 'install' | 'enable' | 'disable' | 'delete' | 'purge' | 'update';
/**
* One line in the action log.
@@ -98,7 +99,7 @@ export type ActionEntry = { kind: 'step' | 'log'; text: string };
/**
* A table in the database that no code on this machine claims.
*
* Not an error — a plugin's tables outlive it deliberately, so an uninstalled-but-present plugin still
* Not an error — a plugin's tables outlive it deliberately, so a plugin deleted without its data still
* owns its own. These are the ones with nothing left to explain them, and they are worth showing because
* the last time some existed they silently broke an install and were found by reading `\dt` by hand.
*/