Files
platform/src/servers/plugins/install.ts
T
pastilhas e930586878 plugins declare the host binaries they need, and the installer checks
Offscale was self-sufficient. Music is not — it shells out to ffmpeg and
ffprobe — and the way it fails without them is the reason this is a check
rather than a line in a README.

It does not fail. Missing ffprobe means the indexer catches the spawn error
and returns a track carrying its filename and nothing else: no title, artist,
album, duration or embedded lyrics. It then walks the whole library, writes a
complete cache tree and reports success. Five swallowed catches, no log, no
counter, and the only tell is coversSaved: 0 in a report nobody reads.

So `osDependencies` is a manifest field: the binary to probe on PATH, why it
is needed, and a package name per package manager. The shape is taken from
scripts/setup-old/setup.sh rather than invented — probe the binary, case on
$PM — and the names are per-manager rather than canonical-with-overrides
because lib/packages.sh already recorded why that indirection was rejected.
Probing the binary is what makes "built-in on this OS" free: on PATH means the
package map is never consulted.

Four decisions worth naming.

Missing and uninstallable REFUSES the install, first, before a table is
created or a row written — so there is nothing to undo, and the alternative is
a plugin that installs, answers 200 and quietly produces nothing.

The status is on GET /api/plugins and rendered before the button, because the
owner is deciding whether to let the server run a package manager as root and
that needs answering first. Installing by hand and watching it flip to present
is the escape hatch on a machine without passwordless sudo.

Package names get a deliberately narrow regex and reach Bun.spawn as an argv
ARRAY, never a shell. Both halves are load-bearing: the regex means a
metacharacter cannot get there, argv means it would be an argument rather than
syntax if it did. Narrower than package managers actually accept — no `:`, no
`+` version pins — because a plugin needing one wants a conversation.

Success is OBSERVED, not inferred: after installing, the binaries are re-probed.
A package manager exiting 0 having installed something that does not provide
the binary is exactly the failure this exists to catch.

installCommand mirrors lib/packages.sh's pkg_install_now exactly, including
apt's non-interactive environment, so there is one definition of "install a
package" rather than two that drift. sudo always gets -n: under PM2 a password
prompt is not a slow path, it is a hang. brew never escalates.

Verified live. ffmpeg and ffprobe were absent on this machine all evening; the
page showed both missing with the exact root command, the install streamed
`dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`,
and X-Audio-Duration appeared on a stream response for the first time. The
refusal path was exercised against a temporary probe dependency: HTTP 400,
steps: [], reason named.

THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt.

Found on the way: a manifest is read once per process. Discovery does
`await import()` and the module cache holds it, so editing a manifest changes
nothing until pm2 restart officer — including `outdated`. Cost ten minutes and
is now in the runbook.

bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new.
2026-08-15 02:33:18 +00:00

275 lines
13 KiB
TypeScript

import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb';
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 { refreshPluginMounts, snapshotPlugins } from './mount';
import { generatePluginSchemas, pushSchema } from './schema';
import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-deps';
import 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;
}
/**
* 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): Promise<PluginActionResult> {
const plugin = await findPlugin(appName);
if (!plugin) return { ok: false, appName, steps: [], error: `No plugin directory named "${appName}"` };
const steps: string[] = [];
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(),
};
}
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
const installed = await installDependencies(deps.manager!, deps.packagesToInstall);
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`);
}
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);
const pushed = await pushSchema();
if (!pushed.ok) {
return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` };
}
await step(steps, onStep, `schema: applied in ${pushed.ms}ms`);
}
await recordPluginInstall(appName, plugin.manifest.version);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
// 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): 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');
}
await step(steps, onStep, 'tables and data: untouched');
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);
}