A preference sounds helpful and fails silently. When the wished-for number is taken the plugin is handed a different one, the install succeeds, and whatever was configured against the old number goes on pointing at nothing - with nobody told. This afternoon that nearly happened: 3011 was only free because fofos-dev had been stopped twenty minutes earlier, and had it still been running the site would have been given a random port behind an nginx host still aimed at 3011. So the number is always drawn fresh and always kept. Always drawn means it is never something you can assume; always kept means the proxy host is configured against it exactly once. The failure mode that remains - having to go and read the number - is one that announces itself, which the other one did not. isFree went with it; it existed only to test a wish.
702 lines
35 KiB
TypeScript
702 lines
35 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 { allocatePluginPort, releasePluginPorts, 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,
|
|
/**
|
|
* Clone from HERE instead of from the catalogue.
|
|
*
|
|
* For a plugin that is not in any registry and should not be — one website's admin panel, something
|
|
* built for one person. Ignored when the plugin is already on disk, because then there is nothing to
|
|
* fetch and a URL would be describing a decision already made.
|
|
*/
|
|
source?: string,
|
|
): 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, source);
|
|
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)) {
|
|
/*
|
|
Ports the manifest asked the platform to own, allocated before the entry is written because the
|
|
entry carries them.
|
|
|
|
Idempotent: a plugin that already has an allocation gets the same number back, so re-installing
|
|
and upgrading do not move a port somebody has written into an nginx proxy host. See
|
|
`pluginPorts` in officerdb for why the platform allocates these when it allocates nothing else.
|
|
*/
|
|
const ports: Record<string, number> = {};
|
|
for (const declared of plugin.manifest.extraPorts ?? []) {
|
|
ports[declared.name] = await allocatePluginPort(appName, declared.name);
|
|
}
|
|
if (Object.keys(ports).length) {
|
|
await step(
|
|
steps,
|
|
onStep,
|
|
`ports: ${Object.entries(ports)
|
|
.map(([name, port]) => `${name}=${port}`)
|
|
.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
addPluginToEcosystem(plugin, ports);
|
|
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(', ')}`);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Ports go here and NOT on uninstall, which is the deliberate asymmetry.
|
|
|
|
An allocated number is written down where this database cannot see it — an nginx proxy host, a
|
|
firewall rule, somebody's notes. Uninstalling is reversible and often temporary, so giving the number
|
|
up there would let a later plugin take it and point that configuration at the wrong service. Purge is
|
|
the act that says the plugin is gone for good, and that is when the number goes back in the pool.
|
|
*/
|
|
const released = await releasePluginPorts(appName);
|
|
if (released) await step(steps, onStep, `ports released: ${released}`);
|
|
|
|
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);
|
|
}
|