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:
@@ -78,9 +78,11 @@ Music added the field; see its PLUGIN.md for what it is guarding against.
|
|||||||
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||||
anywhere else means two systems, and only one gets tested.
|
anywhere else means two systems, and only one gets tested.
|
||||||
|
|
||||||
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
**Only an explicit tick destroys data.** The generated schema barrel follows plugin **directories**, not
|
||||||
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
the install table — `db:push` drops what it cannot see, so following installs would delete a plugin's
|
||||||
on uninstall. Only deleting a plugin's source can lose its data.
|
tables the moment it was stopped. Deleting a plugin removes its directory, and the confirmation's
|
||||||
|
"also delete all its data" checkbox is the one thing that drops its tables. Unticked, the rows outlive
|
||||||
|
the plugin and show up in the orphan list on the same screen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,9 +127,9 @@ Then through `/plugins`, watching PM2 and the browser at each step:
|
|||||||
| permissions page | its permission is listed, read/write/none |
|
| permissions page | its permission is listed, read/write/none |
|
||||||
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||||
| enable | comes back |
|
| enable | comes back |
|
||||||
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
| delete, checkbox unticked | route gone, `pm2 list` loses it, directory gone, **data still there** |
|
||||||
| `bun db:push` while uninstalled | **do not run this — see "What changed on 2026-08-16"** |
|
| install again | fresh clone, and it finds those rows |
|
||||||
| install again | identical to the first install |
|
| delete, checkbox ticked | tables dropped too — a machine that never had it |
|
||||||
|
|
||||||
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||||
|
|
||||||
@@ -220,15 +222,21 @@ no terminal it applies NOTHING and exits 0, so an install reports success and th
|
|||||||
query with 42P01. `--force` would be worse if it worked: the destructive statements are `drop table` for
|
query with 42P01. `--force` would be worse if it worked: the destructive statements are `drop table` for
|
||||||
every plugin whose directory is absent.
|
every plugin whose directory is absent.
|
||||||
|
|
||||||
So: the row in the verification table that says to run `bun db:push` while uninstalled is struck out. The
|
So: the row in the verification table that said to run `bun db:push` while stopped is gone. The property
|
||||||
property it was checking — uninstall does not destroy data — is now checked by reinstalling and finding
|
it was checking — stopping a plugin does not destroy data — is now checked by deleting it with the
|
||||||
your rows still there.
|
checkbox unticked, installing again, and finding your rows still there.
|
||||||
|
|
||||||
**Use `purge` to test the install twice.** Uninstall deliberately keeps the directory and the tables, so a
|
**Delete WITH the checkbox to test the install twice.** Anything less is not a fresh install: the
|
||||||
second install is not a fresh one: the marketplace hides a plugin whose directory exists, and the tables
|
marketplace hides a plugin whose directory exists, and tables that are already there make `db:push` a
|
||||||
are already there. The Marketplace screen has "Delete <plugin> and all its data" — it drops the tables,
|
no-op, so a broken schema stays broken and passes. Ticked, it drops the tables, deletes the directory and
|
||||||
deletes the directory and regenerates the barrel, and afterwards the plugin installs from the catalogue
|
regenerates the barrel — and afterwards the plugin installs from the catalogue exactly as a stranger's
|
||||||
exactly as a stranger's machine would. **That is the only honest test of an install.**
|
machine would. **That is the only honest test of an install.**
|
||||||
|
|
||||||
|
**Uninstall was removed on 2026-08-21.** It sat between disable and delete, keeping the directory and the
|
||||||
|
data, and the screen could not show the difference: an uninstalled plugin stayed in the list under a
|
||||||
|
heading that said "Installed" while its directory silently decided what the next install would run. That
|
||||||
|
is not hypothetical — it is how a jellyfin install on alpha re-applied a checkout from two days earlier,
|
||||||
|
reporting success at every step. The verbs are now enable, disable and delete.
|
||||||
|
|
||||||
**The install now verifies its own tables.** After the push it asks Postgres whether each declared table
|
**The install now verifies its own tables.** After the push it asks Postgres whether each declared table
|
||||||
exists and fails the install naming what is missing. If your extraction reports "schema push reported
|
exists and fails the install naming what is missing. If your extraction reports "schema push reported
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
pluginProcessStatus,
|
pluginProcessStatus,
|
||||||
setPluginRunning,
|
setPluginRunning,
|
||||||
purgePlugin,
|
purgePlugin,
|
||||||
uninstallPlugin,
|
|
||||||
type PluginActionResult,
|
type PluginActionResult,
|
||||||
} from '../../plugins/install';
|
} from '../../plugins/install';
|
||||||
import { updatePlugin, syncCapabilities } from '../../plugins/update';
|
import { updatePlugin, syncCapabilities } from '../../plugins/update';
|
||||||
@@ -218,15 +217,20 @@ pluginsRouter.post('/:appName/install', async (ctx) => {
|
|||||||
return ctx.json(result, result.ok ? 200 : 400);
|
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
|
// ── Two doors, because "delete" means two things ──
|
||||||
// nothing can reach it by passing a body field to the verb people already use.
|
//
|
||||||
pluginsRouter.post('/:appName/purge', async (ctx) => {
|
// `delete` takes the plugin off the machine and leaves its tables. `purge` takes the data as well. Two
|
||||||
return ctx.json(await purgePlugin(ctx.req.param('appName')));
|
// 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) => {
|
pluginsRouter.post('/:appName/purge', async (ctx) => {
|
||||||
const result = await uninstallPlugin(ctx.req.param('appName'));
|
return ctx.json(await purgePlugin(ctx.req.param('appName'), { dropData: true }));
|
||||||
return ctx.json(result, result.ok ? 200 : 400);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pluginsRouter.post('/:appName/enable', async (ctx) => {
|
pluginsRouter.post('/:appName/enable', async (ctx) => {
|
||||||
@@ -239,7 +243,7 @@ pluginsRouter.post('/:appName/disable', async (ctx) => {
|
|||||||
return ctx.json(result, result.ok ? 200 : 400);
|
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
|
// 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
|
// 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 = {
|
const VERBS = {
|
||||||
install: (appName, onStep, userId, onLog) => installPlugin(appName, onStep, userId, onLog),
|
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),
|
enable: (appName, onStep) => setPluginRunning(appName, true, onStep),
|
||||||
disable: (appName, onStep) => setPluginRunning(appName, false, 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
|
// 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.
|
// route so it inherits the streaming door and the UI that already drives install.
|
||||||
update: (appName, onStep, userId, onLog) => updatePlugin(appName, onStep, userId, onLog),
|
update: (appName, onStep, userId, onLog) => updatePlugin(appName, onStep, userId, onLog),
|
||||||
|
|||||||
@@ -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
|
* NOT a verb any more. Until 2026-08-21 this was its own button beside Disable, and the screen offered
|
||||||
* rather than a fresh start, which is the whole reason not to drop anything here.
|
* 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> {
|
export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise<PluginActionResult> {
|
||||||
const steps: string[] = [];
|
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.
|
* 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
|
* `dropData` is the difference between the two things "delete" can mean, and they are not close enough to
|
||||||
* table and row survives, because "stop running this" must never mean "delete my data" — a reinstall
|
* guess at. WITHOUT it the plugin leaves the machine and its tables stay: the platform stops offering it,
|
||||||
* puts you back where you were, which is the property that makes uninstalling safe to try.
|
* 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
|
* It is an argument rather than a body field on one route: `POST /:appName/delete` and
|
||||||
* second one finds the directory already there, so the catalogue hides it, and finds the tables already
|
* `POST /:appName/purge` are separate doors, so nothing reaches the destructive one by adding a field to
|
||||||
* there, so `db:push` has nothing to do and a broken schema stays broken. This is the verb for that, and
|
* the request people already send. The UI asks with a checkbox and picks the door.
|
||||||
* it is deliberately a different word with a different button and a confirmation.
|
*
|
||||||
|
* 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 ──
|
* ── 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
|
* 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.
|
* 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 steps: string[] = [];
|
||||||
const plugin = await findPlugin(appName);
|
const plugin = await findPlugin(appName);
|
||||||
if (!plugin) return { ok: false, appName, steps, error: `"${appName}" is not on this machine` };
|
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) {
|
if (tables.length) {
|
||||||
const present = await existingTables(tables);
|
const present = await existingTables(tables);
|
||||||
await dropTables(present);
|
if (dropData) {
|
||||||
await step(steps, onStep, present.length ? `dropped: ${present.join(', ')}` : 'no tables to drop');
|
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 });
|
await rm(plugin.dir, { recursive: true, force: true });
|
||||||
@@ -586,7 +615,13 @@ export async function purgePlugin(appName: string, onStep?: OnStep, onLog?: OnLo
|
|||||||
generatePluginSchemas(plugins);
|
generatePluginSchemas(plugins);
|
||||||
await step(steps, onStep, 'schema barrel regenerated');
|
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 };
|
return { ok: true, appName, steps };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// wired. Removed rather than left dangling — an export with no caller is an unfinished thought pretending
|
||||||
// to be a feature.
|
// to be a feature.
|
||||||
//
|
//
|
||||||
// The behaviour it would have served is right anyway, for a reason worth stating. Uninstall keeps the
|
// The behaviour it would have served is right anyway, for a reason worth stating. Disabling a plugin keeps
|
||||||
// plugin's DIRECTORY (see the runbook), so `discoverPlugins` still finds it and its packages are still
|
// its DIRECTORY, so `discoverPlugins` still finds it and its packages are still declared — a disabled
|
||||||
// declared. Only "Delete … and all its data" removes the directory, and after that the next install of
|
// plugin that is re-enabled needs them, and nothing had to remember that. Deleting removes the directory,
|
||||||
// any plugin regenerates the merged file without it. So the packages linger until the next install and
|
// and the next install of any plugin regenerates the merged file without it. So the packages linger until
|
||||||
// then go, with nothing needing to remember them.
|
// then and go on their own.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
usePlugins,
|
usePlugins,
|
||||||
type AvailablePlugin,
|
type AvailablePlugin,
|
||||||
@@ -15,7 +17,7 @@ import {
|
|||||||
type PluginVerb,
|
type PluginVerb,
|
||||||
} from './usePlugins';
|
} 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
|
// Reads `?selected=` itself rather than being handed a plugin by the list — neither panel tells the other
|
||||||
// anything, so they cannot disagree.
|
// 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
|
* ── Why a checkbox and not two buttons ──
|
||||||
* 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
|
* "Delete" is one intention with two scopes, and which one somebody wants depends on why they are
|
||||||
* plugin. A modal that says "Are you sure?" tests whether you can click twice, not whether you know what
|
* deleting. Moving a plugin aside to reinstall it means keeping the rows; getting a machine back to
|
||||||
* you are about to lose.
|
* 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
|
* ── Only when it is not running ──
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Nothing is lost by the gate. The state this exists for — a directory sitting on disk keeping the plugin
|
* Offered on a disabled or not-installed plugin, never a live one. Disable is a single click and it is
|
||||||
* out of the marketplace — is by definition an uninstalled one.
|
* 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 DeletePlugin = ({
|
||||||
const [confirming, setConfirming] = useState(false);
|
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
|
if (plugin.enabled) return null;
|
||||||
// a hunt for what would enable it, and the answer here is a different button three inches away.
|
|
||||||
if (plugin.installed) return null;
|
|
||||||
|
|
||||||
if (!confirming) {
|
const hasTables = !!plugin.has?.schema;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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>
|
<Button
|
||||||
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs text-duck-dark/60">
|
tone="danger"
|
||||||
<li>
|
disabled={busy}
|
||||||
its tables are dropped — every row the plugin stored is gone
|
onClick={() => {
|
||||||
{plugin.has?.schema ? '' : ' (this one declares no tables)'}
|
setDropData(false); // never inherited from the last time this was opened
|
||||||
</li>
|
setOpen(true);
|
||||||
<li>
|
}}
|
||||||
the source directory <code>plugins/{plugin.appName}</code> is deleted
|
>
|
||||||
</li>
|
Delete
|
||||||
<li>it stops, unmounts, and its sidecar entry is removed</li>
|
</Button>
|
||||||
</ul>
|
|
||||||
<p className="mt-2 text-xs text-duck-dark/60">
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
Afterwards it can be installed fresh from the marketplace. This cannot be undone.
|
<DialogContent className="sm:max-w-md">
|
||||||
</p>
|
<DialogHeader>
|
||||||
<div className="mt-3 flex gap-2">
|
<DialogTitle>Delete {plugin.label}?</DialogTitle>
|
||||||
<Button tone="danger" disabled={busy} onClick={onPurge}>
|
</DialogHeader>
|
||||||
Delete everything
|
|
||||||
</Button>
|
<ul className="list-disc space-y-0.5 pl-5 text-xs text-duck-dark/60">
|
||||||
<Button disabled={busy} onClick={() => setConfirming(false)}>
|
<li>it stops, unmounts, and its sidecar entry is removed</li>
|
||||||
Cancel
|
<li>
|
||||||
</Button>
|
the source directory <code>plugins/{plugin.appName}</code> is deleted
|
||||||
</div>
|
</li>
|
||||||
</div>
|
<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
|
Install
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : null}
|
||||||
|
{!plugin.installed ? (
|
||||||
|
<DeletePlugin
|
||||||
|
plugin={plugin}
|
||||||
|
busy={busy}
|
||||||
|
onDelete={(dropData) => run(dropData ? 'purge' : 'delete', plugin.appName)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{plugin.installed ? (
|
||||||
<>
|
<>
|
||||||
{plugin.enabled ? (
|
{plugin.enabled ? (
|
||||||
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
|
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
|
||||||
@@ -476,24 +523,26 @@ export const PluginDetail = () => {
|
|||||||
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
|
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
|
||||||
{plugin.outdated ? `Update to ${plugin.version}` : 'Update'}
|
{plugin.outdated ? `Update to ${plugin.version}` : 'Update'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button tone="danger" disabled={busy} onClick={() => run('uninstall', plugin.appName)}>
|
{/* Absent while enabled — disable first. `DeletePlugin` decides that itself rather than being
|
||||||
Uninstall
|
conditioned here, so the rule lives next to the confirmation that explains it. */}
|
||||||
</Button>
|
<DeletePlugin
|
||||||
|
plugin={plugin}
|
||||||
|
busy={busy}
|
||||||
|
onDelete={(dropData) => run(dropData ? 'purge' : 'delete', plugin.appName)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActionLog steps={steps} result={result} running={running} />
|
<ActionLog steps={steps} result={result} running={running} />
|
||||||
|
|
||||||
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
|
{/* Disable and delete are different in kind, not in degree, so the screen says which is which
|
||||||
leaving someone to guess whether the button destroys their data. */}
|
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">
|
<p className="mt-4 text-xs text-duck-dark/40">
|
||||||
{plugin.installed
|
{plugin.installed
|
||||||
? 'Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes anything the plugin stored.'
|
? '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 it below gets you a fresh clone.'}
|
: '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>
|
</p>
|
||||||
|
|
||||||
<Purge plugin={plugin} busy={busy} onPurge={() => run('purge', plugin.appName)} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const PluginsList = () => {
|
|||||||
* Tables in the database that no code on this machine claims.
|
* 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
|
* 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.
|
* this at all.
|
||||||
*
|
*
|
||||||
* It exists because the last time these were present nothing said so. `bun db:push` saw tables the schema
|
* 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>
|
</ul>
|
||||||
<p className="mt-1.5 text-[11px] text-duck-dark/35">
|
<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
|
Kept, not deleted — a plugin deleted without ticking "also delete all its data" leaves these behind. Install
|
||||||
them yourself if they are from something gone for good.
|
the plugin to adopt them, or drop them yourself if they are from something gone for good.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient, getHeaders } from 'hooks/useClient';
|
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
|
// 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.
|
// asking questions; this installs plugins from the tree and asks nothing.
|
||||||
@@ -84,7 +84,8 @@ export type UpdateAllResult = {
|
|||||||
failed: { appName: string; error?: string }[];
|
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.
|
* 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.
|
* 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
|
* 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.
|
* the last time some existed they silently broke an install and were found by reading `\dt` by hand.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user