Files
platform/src/servers/plugins/install.ts
T
pastilhasandClaude Opus 5 52f209c69f caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like
every other one. a fresh machine runs five processes instead of seven and gets a
calendar by installing one plugin, which is the whole point: the platform stops
carrying a python calendar server for people who may not want one.

what left: the radicale sidecar, four routers, the panel apps, the
dav_app_passwords table, the two screens, the two dock tiles, the permission
registry entry, the settings section, and radicale from every package list.

three platform things had to change first, each committed on its own:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.

two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.

/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.

docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.

verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:54:42 +00:00

661 lines
33 KiB
TypeScript

import { join } from 'node:path';
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 { grantPluginToAllRoles } from './grant-on-install';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { publishExtension, unpublishExtension, extensionFileName } from './zip';
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, deps.architectures)
: { 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}/`);
}
// A browser extension, zipped from `extension/` and served UNAUTHENTICATED — see zip.ts. Built here
// rather than committed, so it cannot fall behind the directory it is made from.
if (await publishExtension(appName, plugin.dir).catch(() => false)) {
const file = extensionFileName(appName, join(plugin.dir, 'extension'));
await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`);
}
// GRANT IT TO EVERYONE. Installing a plugin is a deliberate administrative act, and requiring a second
// decision in a different screen to make it reachable has one reliable outcome: it gets forgotten and
// the plugin looks broken. The owner revokes what should not be there.
//
// Rows, not a rule change — authorize.ts still denies on absence. See grant-on-install.ts.
try {
const { granted, roles } = await grantPluginToAllRoles(plugin);
if (granted) await step(steps, onStep, `access: granted to ${roles.join(', ')}`);
} catch (err) {
// Not fatal, and said out loud rather than swallowed: the plugin is installed and the owner can reach
// it, so the grants can be added by hand. Silently installing something nobody can reach is the exact
// failure this change removes, so it must not become a silent failure of its own.
await step(steps, onStep, `access: NOT granted — ${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.
//
// One pin per SCREEN, and the route is the SCREEN's, not the mount prefix. Caldav pins /calendar and
// /contacts; pinning /caldav would put a tile on the dock pointing at a route no plugin serves, since
// a plugin declaring screens mounts at the segments it chose rather than at its name.
if (plugin.web && forUserId !== undefined) {
for (const screen of plugin.web.screens) {
const route = screen.segment ? `/${screen.segment}` : 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) };
}
}
/**
* Stop answering, stop running, forget — the first half of deleting a plugin.
*
* 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[] = [];
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);
await unpublishExtension(appName, plugin?.dir).catch(() => {});
// 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.
// 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.
await step(
steps,
onStep,
'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)',
);
return { ok: true, appName, steps };
}
/**
* Delete a plugin completely: its rows, its tables, and its source.
*
* ── One function, two verbs, and the checkbox that chooses ──
*
* `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.
*
* 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 ──
*
* 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 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` };
// 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);
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 });
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,
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 };
}
/** 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);
}