Files
platform/src/servers/plugins/install.ts
T
pastilhas 21bedf1418 npmDependencies in the manifest, installed once for every plugin
replaces the per-plugin package.json from earlier tonight. the owner's call,
and the reason is one i had not weighed: sharp is ~50 MB of platform binaries
and the obvious dependency for anything touching images, so a second and third
plugin wanting it would have meant a second and third copy with no version they
could converge on.

so every plugin's `npmDependencies` merge into ONE generated plugins/package.json,
installed into plugins/node_modules/. bun resolves node_modules up the tree, so
that directory serves every plugin and none of the platform's own code.

ranges intersect rather than alternate. two plugins asking for ^0.33.5 and
^0.33.9 produce "^0.33.5 ^0.33.9", which npm and bun read as AND — the resolved
version must satisfy both. `||` would have meant "either", which is the silent
version of this failure: one plugin quietly getting a version the other cannot
import. incompatible requirements now fail the install with bun's own message,
and both askers are named in the steps either way.

generated from the plugins ON DISK, like generatePluginSchemas, and never
edited — so a package cannot appear there without a plugin having declared it.

what this gives up, knowingly: a committed lockfile. ranges are the whole point
and a range resolves per machine, so plugin packages are resolved at install
time like osDependencies are, and unlike the platform's own. CLAUDE.md is
emphatic about frozen lockfiles and 2026-08-04, so it is named in the manifest
type rather than left to be discovered.

the generated plugins/package.json and lockfile are gitignored — /plugins/*/
only ignores directories, so files at that level needed naming explicitly.
2026-08-20 18:22:58 +00:00

632 lines
32 KiB
TypeScript

import {
deleteScreenForAllUsers,
getDockPaths,
getPluginInstall,
recordPluginInstall,
removePluginInstall,
setDockPaths,
setPluginEnabled,
} from 'officerdb';
import { corePermissionCollisions } from '../permissions/registry';
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';
import { discoverPlugins } from './discover';
import { fetchPlugin } from './fetch';
import { revokeGrantsForPermissions } from 'officerdb';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
import { publishSkills, unpublishSkills } from '../skills/publish';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { generatePluginSchemas, pushPluginSchema } from './schema';
import { declaredTables, dropTables, existingTables } from './tables';
import type { OnLog } from './run-logged';
import { installDependencies, installRelease, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
import { anyPluginWantsPackages, installNpmDependencies, unresolvableDependencies } from './npm-deps';
import { mountPrefix, type DiscoveredPlugin } from './manifest';
// The install runner: the four verbs, each as a short ordered list of effects.
//
// ── Order is the whole design ──
//
// Every verb does its work in the order that leaves the system coherent if it stops halfway, because it
// can. Bringing something UP goes outside-in (make it possible, then start it, then record it, then
// expose it); taking something DOWN goes inside-out (stop exposing it, stop it, then forget it). The
// worst intermediate state is then "recorded but not running", which the UI can show and a retry fixes —
// never "running but forgotten", which nothing can see and nothing will clean up.
//
// ── What each verb touches ──
//
// host deps ecosystem PM2 row mounts tables
// install install add start upsert rebuild (see below)
// uninstall untouched remove delete delete rebuild untouched
// enable untouched — start enabled=t rebuild untouched
// disable untouched — stop enabled=f rebuild untouched
//
// Host dependencies are installed and never removed. A package is a machine-wide resource that other
// things may have started depending on the moment it appeared, so uninstalling a plugin has no business
// deciding that `ffmpeg` should go — the same reasoning as tables, one level down.
//
// Nothing here drops a table, ever. Uninstall means "stop running this", and for a plugin holding a
// user's data the two are unrecoverably different — see `plugin_installs` schema.
//
// The schema push happens on install and NEVER on uninstall. `drizzle-kit push` drops what it cannot
// see, so the generated barrel follows the plugin DIRECTORIES rather than the install table — uninstall
// leaves both the barrel entry and every row alone, and only deleting a plugin's source can lose data.
// See plugins/schema.ts.
/**
* Reported as each step completes, for the streaming endpoint.
*
* The runner does not know or care whether anyone is listening — it calls this and carries on, so the
* non-streaming path is the same code with no callback rather than a second implementation.
*/
export type OnStep = (step: string) => void | Promise<void>;
/**
* A beat between steps. Deliberate, and not decoration — do not remove it as a pointless sleep.
*
* Some of this work is genuinely slow (`pm2 start` measures ~770ms) and some is not: writing a row and
* rebuilding the router are effectively instant. Without a pause the fast steps arrive in a single frame,
* so the log jumps from empty to finished and the reader cannot tell "it worked" from "nothing happened".
* The interval IS the feedback — it is what makes each step something you saw occur rather than something
* you found already done.
*
* Small enough that a script does not care, long enough that a person can follow it. Only applied when
* something is listening: `onStep` absent means no beat, so the JSON path runs at full speed.
*/
const STEP_BEAT_MS = 220;
export type PluginActionResult = {
ok: boolean;
appName: string;
/** What actually happened, in order. Returned so the UI can show a real account rather than a spinner. */
steps: string[];
/** Present when a step failed. The plugin is left in the last coherent state above. */
error?: string;
};
/** PM2 is only involved when the plugin actually has a sidecar. Most won't. */
const hasSidecar = (plugin: DiscoveredPlugin) => !!plugin.sidecar;
/** Record a step, tell whoever is listening, and pause so the next one does not land in the same frame. */
async function step(steps: string[], onStep: OnStep | undefined, text: string): Promise<void> {
steps.push(text);
await onStep?.(text);
if (onStep) await Bun.sleep(STEP_BEAT_MS);
}
async function findPlugin(appName: string): Promise<DiscoveredPlugin | null> {
const { states } = await snapshotPlugins();
return states.find((s) => s.plugin.appName === appName)?.plugin ?? null;
}
/**
* Put the new plugin's tile on the installing user's dock.
*
* Only for a user who has SAVED a dock. That list is a complete, ordered replacement — `useDock` renders
* it rather than everything available — so a plugin installed after they arranged it is invisible until
* they go to Profile → Dock and add it by hand. Installing something is already the decision to use it;
* making them say so twice is the bug.
*
* A user with no row is deliberately left alone. There is nothing to append to, and writing one here
* would freeze today's defaults into their account forever — the frontend covers that case instead, by
* including plugin tiles in the default itself (`defaultDockPaths` in Dock.tsx).
*
* Appended, never inserted, so it lands at the end where a new thing belongs and their ordering survives.
*
* Best-effort by design: the plugin is installed and working at this point, and failing the whole install
* over a dock preference would be the tail wagging the dog.
*/
async function addToDock(userId: number, route: string): Promise<boolean> {
try {
const saved = await getDockPaths(userId);
if (!saved || saved.includes(route)) return false;
await setDockPaths(userId, [...saved, route]);
return true;
} catch {
return false;
}
}
/**
* Install, or upgrade one already installed.
*
* Idempotent by construction: the ecosystem entry is replaced rather than appended, the row is an upsert,
* and the mount is a rebuild. Re-running after a failure resumes rather than duplicating.
*
* `enabled` is deliberately untouched on the upgrade path — re-installing something the owner had
* switched off must not switch it back on.
*/
export async function installPlugin(
appName: string,
onStep?: OnStep,
forUserId?: number,
onLog?: OnLog,
): Promise<PluginActionResult> {
const steps: string[] = [];
// FETCH FIRST, when the plugin is not here yet.
//
// Until 2026-08-15 every plugin shipped inside the platform's repository, so "installed" and "present"
// were nearly the same thing and install could assume a directory. Music moving to its own repository
// ended that: it is real, installable, and absent. So the first question is no longer "is it enabled"
// but "is it even on this machine".
//
// Cloning before anything else keeps the same property every other step here has — it changes nothing
// that needs undoing if it fails. A failed clone removes its own directory and the machine is exactly
// as it was.
let plugin = await findPlugin(appName);
if (!plugin) {
const fetched = await fetchPlugin(appName, onLog);
if (!fetched.ok) {
return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` };
}
await step(steps, onStep, `fetched: cloned into plugins/${appName}`);
// Re-discovered rather than assumed. The catalogue DESCRIBED this plugin; the manifest on disk is
// what the platform actually obeys, and a catalogue that disagreed with it is a catalogue bug rather
// than a second source of truth.
plugin = await findPlugin(appName);
if (!plugin) {
return { ok: false, appName, steps, error: `cloned "${appName}" but it does not read as a plugin` };
}
}
// PERMISSION KEYS BEFORE ANYTHING IS WRITTEN.
//
// A key the platform still claims is refused by `setPluginPermissions` at mount time — correctly, but
// too late and too quietly: the install completes, the dock tile appears, the frontend rebuilds with
// the route in it, and every request 404s because the plugin never mounted. The only trace is one line
// on stderr.
//
// It is not a hypothetical. photos, soulseek and jellyfin each hit it within a day of being extracted,
// always the same way: the plugin's key was a CORE permission until the extraction commit, so any
// server whose platform predates that commit still claims it. The fix is always `git pull`, and the
// symptom never says so.
const collisions = corePermissionCollisions(plugin.manifest.permissions.map((p) => p.key));
if (collisions.length) {
return {
ok: false,
appName,
steps,
error:
`the platform still defines ${collisions.map((c: string) => `"${c}"`).join(', ')} as a core permission, ` +
`so "${appName}" cannot claim it. This server's platform predates the commit that extracted ` +
`${appName} — pull the platform and restart officer, then install again.`,
};
}
try {
// HOST BINARIES FIRST, before a table is created or a row is written.
//
// Music is why. Without `ffprobe` its indexer does not fail — it writes a complete library where
// every track is a bare filename with no cover, and reports success. So a plugin whose dependencies
// cannot be satisfied must not install at all: the alternative is a plugin that runs, answers 200,
// and quietly produces nothing, which is far harder to diagnose than a refusal.
//
// First because it is the only step that can refuse for a reason the owner can act on, and refusing
// before any effect means there is nothing to undo. Everything below this line changes the machine.
const deps = reportDependencies(plugin);
if (deps.missing.length) {
if (!deps.canInstall) {
const hint = manualInstallHint(deps.manager, deps.packagesToInstall);
return {
ok: false,
appName,
steps,
error:
`${appName} needs ${deps.missing.map((d) => d.binary).join(', ')}, which ${deps.missing.length === 1 ? 'is' : 'are'} not installed. ` +
`${deps.blockedReason ?? ''}${hint ? ` Run: ${hint}` : ''}`.trim(),
};
}
// Release binaries first, and separately: they need no package manager, which is the reason they
// exist. `tea` is the motivating case — Gitea publishes it from dl.gitea.com, and the Debian
// package of that name is an unrelated text editor.
for (const dep of deps.missing.filter((d) => d.releaseUrl)) {
await step(steps, onStep, `dependencies: fetching ${dep.binary} from ${dep.releaseUrl}`);
const got = await installRelease(dep);
if (!got.ok) {
return { ok: false, appName, steps, error: `could not fetch ${dep.binary}: ${got.error}` };
}
await step(steps, onStep, `dependencies: ${dep.binary} installed to ${got.path}`);
}
if (deps.packagesToInstall.length) {
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
}
const installed = deps.packagesToInstall.length
? await installDependencies(deps.manager!, deps.packagesToInstall)
: { ok: true as const, installed: [], output: '' };
if (!installed.ok) {
return {
ok: false,
appName,
steps,
error: `dependency install failed: ${installed.error ?? installed.output}`,
};
}
// Observed, not inferred. A package manager exiting 0 having installed something that does not
// provide the binary the plugin spawns is exactly the failure this whole check exists to catch,
// and trusting the exit code would reproduce it one layer up.
const absent = stillMissing(plugin);
if (absent.length) {
return {
ok: false,
appName,
steps,
error: `installed ${deps.packagesToInstall.join(', ')}, but ${absent.join(', ')} still ${absent.length === 1 ? 'is' : 'are'} not on PATH`,
};
}
await step(steps, onStep, `dependencies: ${deps.missing.map((d) => d.binary).join(', ')} now on PATH`);
} else if (deps.dependencies.length) {
// Said out loud rather than skipped silently: "it was already there" and "we never looked" are
// different facts, and only one of them is reassuring.
await step(steps, onStep, `dependencies: ${deps.dependencies.map((d) => d.binary).join(', ')} already present`);
}
// ── npm packages, shared across every plugin ──
//
// Beside the OS dependencies above and verified the same way, because the same failure is available:
// an install that exits 0 and still leaves nothing the sidecar can import. Atelier hit exactly that on
// 2026-08-20 — `sharp` in the PLATFORM's package.json, alpha never told to `bun install`, and the
// sidecar crash-looped on a plugin that had reported a clean install.
//
// Generated from every plugin ON DISK rather than from this one, so a shared `sharp` is installed once
// and two plugins wanting it converge instead of carrying a copy each.
{
const { plugins: onDisk } = await discoverPlugins();
if (anyPluginWantsPackages(onDisk)) {
await step(steps, onStep, 'packages: resolving npm dependencies declared by plugins');
const npm = await installNpmDependencies(onDisk);
for (const [name, askers] of Object.entries(npm.conflicts)) {
// Named, not silently intersected. Two plugins disagreeing about a version is a thing their
// authors need to know, and the install either resolves it or fails on the next line.
await step(
steps,
onStep,
`packages: ${name} requested as ${askers.map((a) => `${a.range} (${a.appName})`).join(', ')} — intersected`,
);
}
if (!npm.ok) {
return { ok: false, appName, steps, error: `package install failed: ${npm.output || 'no output'}` };
}
// Resolved, not assumed. `sharp` resolves to a platform-specific binary package, so an install
// that succeeds on one machine can still leave nothing importable on another — which is the shape
// of the failure this whole block exists to stop shipping.
const unresolvable = unresolvableDependencies(plugin);
if (unresolvable.length) {
return {
ok: false,
appName,
steps,
error: `packages installed, but ${unresolvable.join(', ')} cannot be resolved from ${plugin.dir}`,
};
}
await step(steps, onStep, 'packages: installed and resolvable');
}
}
if (plugin.schema) {
// The barrel first, then the push. It is generated from the DIRECTORIES on disk rather than from
// what is installed — see schema.ts for why that difference is the whole safety property.
const { plugins } = await discoverPlugins();
generatePluginSchemas(plugins);
// The table list first: it is both what the push is scoped to and what is verified afterwards.
const { tables, refused } = await declaredTables(plugin);
const pushed = await pushPluginSchema(plugin, tables, onLog);
if (!pushed.ok) {
return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` };
}
// Exit code 0 is not proof the tables exist.
//
// An install reported success on a machine where `offscale_servers` was never created: the plugin
// 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. The scoped push
// above removes the cause; this stays because "the command exited 0" and "the tables are there"
// are different claims, and only one of them is the one that matters.
//
// 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.
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);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
/*
Throw away every saved layout for this plugin's screen, for every user.
A layout holds `appType` STRINGS. Rename a panel key between two versions and every saved layout still
names the old one; `normalizeLayout` then swaps each unknown appType for `allowed[0]` — whichever panel
the plugin lists first — and `WorkspaceView` persists the repair. Renaming `headscale-*` to `offscale-*`
therefore rewrote the owner's layout into three copies of the picker and saved it, which survived a
server restart, a sidecar restart and a reinstall, because nothing on disk was wrong.
Install is the only place that knows a plugin's panels may have just changed shape, so it is where the
stale layout dies. Nothing is lost: a plugin screen is `locked`, so the layout is the author's design
rather than an arrangement someone made, and `useDashboardState` re-seeds from `defaultLayout` on the
next load.
*/
const clearedLayouts = await deleteScreenForAllUsers(`plugin/${appName}`);
if (clearedLayouts) await step(steps, onStep, `layout: reset for ${clearedLayouts} user(s)`);
// The dock tile's artwork, copied where the browser can fetch it. Reuses the app store's publisher —
// `<dir>/assets/` → `public/plugins/<appName>/` — because it already solves the parts that are not
// obvious: the `/plugins/*` route is dynamic precisely so a first install does not show a broken
// image until the next restart, and uninstall is allowed to delete these because they are copies of
// files that still exist in the plugin's source.
// Either asset is reason enough to publish — the copy takes the whole directory, and gating on
// the icon alone left a plugin that ships only a splash serving nothing.
if (plugin.icon || plugin.splash) {
await publishAssets(appName, plugin.dir);
await step(steps, onStep, `assets: published to /plugins/${appName}/`);
}
// What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at
// runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is
// authenticated unless the plugin says so. Same shape as the assets above — a directory in the
// repository, copied to a platform-managed place.
//
// Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install
// over it would cost the owner the plugin.
try {
const published = await publishAffordances(plugin);
if (published) {
await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`);
}
} catch (err) {
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
}
// Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs,
// and its pushes render the unknown-code fallback on the device rather than nothing at all.
try {
const published = await publishNotifications(plugin);
if (published) {
await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`);
}
} catch (err) {
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
}
// Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does
// not learn the reference exists, which is the state every plugin was in before this.
try {
const published = await publishSkills(plugin);
if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`);
} catch (err) {
await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`);
}
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
if (plugin.web && forUserId !== undefined) {
const route = mountPrefix(plugin);
if (await addToDock(forUserId, route)) await step(steps, onStep, `dock: ${route} pinned`);
}
// MOUNT BEFORE STARTING THE SIDECAR, and the order is not cosmetic.
//
// `createSidecarProxy` learns its sidecar's port from a one-shot event (`<name>:server`), and it
// subscribes when the plugin's router module is first imported — which happens here, at mount. Start
// the process first and it announces its port to nobody: the sidecar is online, the routes are
// mounted, and every request answers `503 sidecar not available` until something makes it reconnect.
//
// Found installing offscale, whose sidecar binds its own HTTP server. `example` never caught it
// because it has no listener to announce.
const { mounted } = await refreshPluginMounts();
await step(
steps,
onStep,
mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)',
);
if (hasSidecar(plugin)) {
addPluginToEcosystem(plugin);
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
if (!started.ok) {
// The entry stays. A sidecar that will not start is a plugin to retry or debug, and removing the
// entry would take away the thing that makes `pm2 logs officer-<name>` work.
return { ok: false, appName, steps, error: `sidecar failed to start: ${started.error}` };
}
await step(steps, onStep, 'sidecar: started');
}
return { ok: true, appName, steps };
} catch (err) {
return { ok: false, appName, steps, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Uninstall: stop answering, stop running, forget.
*
* 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.
*/
export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise<PluginActionResult> {
const steps: string[] = [];
const plugin = await findPlugin(appName);
// Row first: the mount rebuild below reads it, and a failure after this point leaves the plugin
// unmounted and stopped rather than half-visible.
const removed = await removePluginInstall(appName);
if (!removed) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
await step(steps, onStep, 'install record removed');
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `unmounted (now: ${mounted.join(', ') || 'no plugin routes'})`);
if (plugin && hasSidecar(plugin)) {
await stopProcess(pluginProcessName(appName), PLATFORM_DIR);
await deleteProcess(pluginProcessName(appName), PLATFORM_DIR);
removePluginFromEcosystem(appName);
await step(steps, onStep, 'sidecar: stopped, deleted, ecosystem entry removed');
}
// The one thing uninstall IS allowed to delete: published assets are copies, and the originals are
// still in the plugin's own directory. Nothing a user made is in there.
// Its grants go too. The keys stop existing when the plugin is unmounted, and a grant naming one is a
// row nobody can see, edit or remove through the UI — which made the whole permissions screen
// unsaveable, because the page submits every grant it loaded and the write refused a key it no longer
// recognised. Reported on a real server after uninstalling music.
//
// This is a deliberate exception to "uninstall deletes nothing": a grant is the PLATFORM's record of
// who may reach the plugin, not anything the plugin stored. Reinstalling means granting again, which
// is the honest behaviour anyway — the alternative is access silently returning.
if (plugin) {
const keys = plugin.manifest.permissions.map((p) => p.key);
const revoked = await revokeGrantsForPermissions(keys).catch(() => 0);
if (revoked) await step(steps, onStep, `grants: ${revoked} removed for ${keys.join(', ')}`);
}
await unpublishAssets(appName);
// Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
// worse than none, because it reads as true.
if (plugin) await unpublishAffordances(plugin).catch(() => {});
// And its notification codes, which stop resolving rather than resolving to something stale. A device
// still holding a queued push renders the unknown-code fallback, which is a defined state.
if (plugin) await unpublishNotifications(plugin).catch(() => {});
if (plugin) await unpublishSkills(plugin).catch(() => {});
await step(steps, onStep, 'tables and data: untouched (published assets, affordances and notifications removed)');
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, 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` };
// 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, onLog);
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,
enabled: boolean,
onStep?: OnStep,
): Promise<PluginActionResult> {
const steps: string[] = [];
const row = await setPluginEnabled(appName, enabled);
if (!row) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
await step(steps, onStep, enabled ? 'enabled' : 'disabled');
const plugin = await findPlugin(appName);
// Enabling mounts BEFORE starting, for the same reason install does: the proxy has to be listening
// before the sidecar announces its port. Disabling is the mirror — stop answering, then stop the
// process — so neither direction leaves a mounted route in front of a sidecar that cannot be reached.
if (enabled) {
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
}
if (plugin && hasSidecar(plugin)) {
const name = pluginProcessName(appName);
const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR);
await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
}
if (!enabled) {
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
}
return { ok: true, appName, steps };
}
/** Whether a plugin's sidecar is actually up, for the UI. `null` when it has none or PM2 has not seen it. */
export async function pluginProcessStatus(plugin: DiscoveredPlugin): Promise<string | null> {
if (!hasSidecar(plugin)) return null;
return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR);
}