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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user