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.
This commit is contained in:
@@ -5,6 +5,7 @@ import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } fr
|
||||
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.
|
||||
@@ -19,11 +20,15 @@ import type { DiscoveredPlugin } from './manifest';
|
||||
//
|
||||
// ── What each verb touches ──
|
||||
//
|
||||
// ecosystem PM2 row mounts tables
|
||||
// install add start upsert rebuild (see below)
|
||||
// uninstall remove delete delete rebuild untouched
|
||||
// enable — start enabled=t rebuild untouched
|
||||
// disable — stop enabled=f rebuild untouched
|
||||
// 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.
|
||||
@@ -94,6 +99,59 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user