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
@@ -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.
*/