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,4 +1,12 @@
|
||||
import { getDockPaths, recordPluginInstall, removePluginInstall, setDockPaths, setPluginEnabled } from 'officerdb';
|
||||
import {
|
||||
getDockPaths,
|
||||
getPluginInstall,
|
||||
recordPluginInstall,
|
||||
removePluginInstall,
|
||||
setDockPaths,
|
||||
setPluginEnabled,
|
||||
} from 'officerdb';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
|
||||
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
||||
@@ -7,6 +15,7 @@ import { fetchPlugin } from './fetch';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import { generatePluginSchemas, pushSchema } from './schema';
|
||||
import { declaredTables, dropTables, existingTables } from './tables';
|
||||
import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
|
||||
@@ -217,7 +226,38 @@ export async function installPlugin(appName: string, onStep?: OnStep, forUserId?
|
||||
if (!pushed.ok) {
|
||||
return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` };
|
||||
}
|
||||
await step(steps, onStep, `schema: applied in ${pushed.ms}ms`);
|
||||
|
||||
// Exit code 0 is not proof the tables exist.
|
||||
//
|
||||
// `db:push` is a compound command — generate the barrel, cd, run drizzle-kit — and an install was
|
||||
// reported as successful on a machine where `offscale_servers` was never created. The plugin then
|
||||
// mounted, its sidecar started, registered, and failed its first query with 42P01. Every step said
|
||||
// OK and the plugin was unusable, which is the worst shape a failure can take.
|
||||
//
|
||||
// So the install asks Postgres. A missing table fails the install HERE, where the message can say
|
||||
// what happened, instead of appearing later as a stack trace in a sidecar log.
|
||||
const { tables, refused } = await declaredTables(plugin);
|
||||
if (refused.length) {
|
||||
// Not fatal: the plugin still works, and the tables are still created by push. But they are
|
||||
// outside its prefix, so `purge` will not remove them and nobody will notice until a reinstall
|
||||
// finds them already there.
|
||||
await step(steps, onStep, `schema: WARNING — ${refused.join(', ')} are not prefixed \`${appName}_\``);
|
||||
}
|
||||
const present = await existingTables(tables);
|
||||
const missing = tables.filter((t) => !present.includes(t));
|
||||
if (missing.length) {
|
||||
return {
|
||||
ok: false,
|
||||
appName,
|
||||
steps,
|
||||
error: `schema push reported success but did not create ${missing.join(', ')}. Run \`bun db:push\` by hand and read its output. Push said: ${pushed.output}`,
|
||||
};
|
||||
}
|
||||
await step(
|
||||
steps,
|
||||
onStep,
|
||||
`schema: applied in ${pushed.ms}ms${tables.length ? ` (${tables.length} table(s) verified)` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
await recordPluginInstall(appName, plugin.manifest.version);
|
||||
@@ -308,6 +348,70 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a plugin completely: its rows, its tables, and its source.
|
||||
*
|
||||
* ── Why this exists next to an uninstall that deliberately keeps everything ──
|
||||
*
|
||||
* `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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* ── Order, and why this one ──
|
||||
*
|
||||
* Tables are read from the schema BEFORE the directory goes, because the list comes from the file that is
|
||||
* about to be deleted. Then: uninstall (stops the sidecar and unmounts — nothing may be running against
|
||||
* tables that are about to vanish), drop, delete, regenerate the barrel.
|
||||
*
|
||||
* The barrel last and unconditionally: it names plugin directories, and one naming a directory that no
|
||||
* 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): Promise<PluginActionResult> {
|
||||
const steps: string[] = [];
|
||||
const plugin = await findPlugin(appName);
|
||||
if (!plugin) return { ok: false, appName, steps, error: `"${appName}" is not on this machine` };
|
||||
|
||||
// Read the table list while the source still exists.
|
||||
const { tables, refused } = await declaredTables(plugin).catch(() => ({ tables: [], refused: [] }));
|
||||
if (refused.length) {
|
||||
// Said out loud rather than silently skipped: these are the plugin's tables by any reasonable reading
|
||||
// and they are being LEFT BEHIND, which the next install will trip over.
|
||||
await step(steps, onStep, `left alone (not prefixed \`${appName}_\`): ${refused.join(', ')}`);
|
||||
}
|
||||
|
||||
// Stop it first. Dropping a table under a running sidecar gives it a 42P01 instead of a shutdown.
|
||||
if (await getPluginInstall(appName)) {
|
||||
const uninstalled = await uninstallPlugin(appName, onStep);
|
||||
if (!uninstalled.ok) return { ...uninstalled, steps: [...steps, ...uninstalled.steps] };
|
||||
steps.push(...uninstalled.steps);
|
||||
} else {
|
||||
await step(steps, onStep, 'not installed — nothing to stop');
|
||||
}
|
||||
|
||||
if (tables.length) {
|
||||
const present = await existingTables(tables);
|
||||
await dropTables(present);
|
||||
await step(steps, onStep, present.length ? `dropped: ${present.join(', ')}` : 'no tables to drop');
|
||||
}
|
||||
|
||||
await rm(plugin.dir, { recursive: true, force: true });
|
||||
await step(steps, onStep, `deleted ${plugin.dir}`);
|
||||
|
||||
// From the directories that are left, so the barrel stops naming one that is gone.
|
||||
const { plugins } = await discoverPlugins();
|
||||
generatePluginSchemas(plugins);
|
||||
await step(steps, onStep, 'schema barrel regenerated');
|
||||
|
||||
await step(steps, onStep, `"${appName}" can now be installed fresh from the marketplace`);
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
/** Enable: mount and run again. Disable: the reversible middle — unmount and stop, keep everything. */
|
||||
export async function setPluginRunning(
|
||||
appName: string,
|
||||
|
||||
Reference in New Issue
Block a user