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:
2026-08-15 02:33:18 +00:00
parent 05eb947bd1
commit e930586878
10 changed files with 713 additions and 16 deletions
+22
View File
@@ -3,6 +3,7 @@ import * as errors from '../../custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { mountPrefix } from '../../plugins/manifest';
import { snapshotPlugins } from '../../plugins/mount';
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
import {
installPlugin,
pluginProcessStatus,
@@ -43,6 +44,20 @@ pluginsRouter.get('/', async (ctx) => {
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
// rendering differently — it is the difference between "off" and "broken".
const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
// Synchronous and cheap — `Bun.which` per declared binary, and most plugins declare none. The one
// genuinely slow call in here is the `sudo -n` probe, which only runs when something is missing.
const dependencyReports = states.map(({ plugin }) => {
const report = reportDependencies(plugin);
return {
manager: report.manager,
items: report.dependencies,
satisfied: report.missing.length === 0,
canInstall: report.canInstall,
blockedReason: report.blockedReason,
packagesToInstall: report.packagesToInstall,
manualHint: manualInstallHint(report.manager, report.packagesToInstall),
};
});
return ctx.json({
plugins: states.map(({ plugin, install, outdated }, i) => ({
appName: plugin.appName,
@@ -67,6 +82,13 @@ pluginsRouter.get('/', async (ctx) => {
installedVersion: install?.version ?? null,
outdated,
processStatus: statuses[i] ?? null,
// Probed live, against this machine's PATH, on every read.
//
// Deliberately here rather than only inside the installer: the owner decides whether to let the
// server run `apt-get install` as root, and that decision needs the answer BEFORE the button, not
// in the log afterwards. It also lets them install the packages by hand first and watch this flip
// to present, which is the escape hatch for a machine with no passwordless sudo.
dependencies: dependencyReports[i]!,
})),
broken,
});