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
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from 'react-router';
import { usePlugins, type PluginItem } from './usePlugins';
import { usePlugins, type PluginDependencies, type PluginItem } from './usePlugins';
// The right panel: one plugin, and the four verbs.
//
@@ -52,6 +52,61 @@ const Parts = ({ has }: { has: PluginItem['has'] }) => {
return <>{present.length ? present.join(' · ') : 'manifest only'}</>;
};
/**
* Host binaries, per binary, before anything is installed.
*
* The whole point is that this is visible BEFORE the button. Music needs `ffprobe`, and without it the
* indexer does not fail — it writes a library where every track is a bare filename and reports success.
* So "what does this need, and does this machine have it" is a question the owner answers first, not
* something they find out from a log.
*
* Rendered only when the plugin declares something. Most declare nothing, and a permanently empty
* "Requires: nothing" row is noise on every other plugin's page.
*/
const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
if (!deps.items.length) return null;
return (
<div className="mt-4 border-t border-duck-dark/10 pt-4">
<p className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Needs on this machine</p>
<ul className="mt-2 space-y-2">
{deps.items.map((item) => (
<li key={item.binary} className="flex gap-2 text-sm">
<span aria-hidden className={item.present ? 'text-emerald-600' : 'text-amber-600'}>
{item.present ? '✓' : '•'}
</span>
<span className="min-w-0">
<code className="text-duck-dark">{item.binary}</code>{' '}
<span className={item.present ? 'text-duck-dark/50' : 'text-amber-700'}>
{item.present ? 'present' : `missing${item.packageName ? `${item.packageName}` : ''}`}
</span>
<span className="block text-xs text-duck-dark/50">{item.reason}</span>
</span>
</li>
))}
</ul>
{/* Three states, and they need different words. Satisfied says nothing further. Installable says
exactly what will be run as root, because that is what is being consented to. Blocked gives the
command to run by hand, which is the whole escape hatch on a machine without passwordless sudo. */}
{deps.satisfied ? null : deps.canInstall ? (
<p className="mt-3 text-xs text-duck-dark/60">
Installing this plugin will run{' '}
<code className="text-duck-dark">
{deps.manager} install {deps.packagesToInstall.join(' ')}
</code>{' '}
as root first.
</p>
) : (
<div className="mt-3 rounded border border-amber-500/30 bg-amber-500/5 p-2.5">
<p className="text-xs text-amber-800">{deps.blockedReason ?? 'These cannot be installed automatically.'}</p>
{deps.manualHint ? <code className="mt-1.5 block text-xs text-duck-dark">{deps.manualHint}</code> : null}
</div>
)}
</div>
);
};
export const PluginDetail = () => {
const { plugins, steps, result, running, run } = usePlugins();
const [params] = useSearchParams();
@@ -105,6 +160,8 @@ export const PluginDetail = () => {
</Row>
</div>
<Dependencies deps={plugin.dependencies} />
{/* The way in, when there is one.
Only while installed AND enabled: a link to a route that is not mounted is a link to the home
page, since the shell redirects an unknown path — which reads as the link being broken rather
@@ -122,7 +179,14 @@ export const PluginDetail = () => {
<div className="mt-6 flex flex-wrap gap-2">
{!plugin.installed ? (
<Button tone="primary" disabled={busy} onClick={() => run('install', plugin.appName)}>
// Disabled when the host binaries cannot be satisfied, because the installer refuses in that
// case — offering a button whose only outcome is an error message is worse than not offering
// it. The reason is already on screen above, so the disabled state is not mysterious.
<Button
tone="primary"
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
onClick={() => run('install', plugin.appName)}
>
Install
</Button>
) : (
@@ -9,6 +9,37 @@ import { useClient, getHeaders } from 'hooks/useClient';
export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
/** One host binary the plugin shells out to, probed against this machine's PATH on every read. */
export type DependencyItem = {
binary: string;
reason: string;
path: string | null;
present: boolean;
/** What would be installed here, or null when this machine's package manager has no entry for it. */
packageName: string | null;
};
/**
* Host binaries, answered for THIS machine.
*
* Shown before the Install button rather than only in the log afterwards, because the owner is deciding
* whether to let the server run a package manager as root — and that decision needs the answer first.
* Installing the packages by hand and watching this flip to satisfied is the escape hatch on a machine
* with no passwordless sudo.
*/
export type PluginDependencies = {
/** apt | pacman | dnf | brew, or null when none was found. */
manager: string | null;
items: DependencyItem[];
satisfied: boolean;
canInstall: boolean;
/** Why the platform cannot install them. Written to be read by a person. */
blockedReason: string | null;
packagesToInstall: string[];
/** The command to run by hand. Display only — the server never executes this string. */
manualHint: string | null;
};
export type PluginItem = {
appName: string;
/** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
@@ -34,6 +65,8 @@ export type PluginItem = {
* plugin that is off and one that is broken.
*/
processStatus: string | null;
/** Host binaries and whether this machine has them. Always present; `items` is empty for most plugins. */
dependencies: PluginDependencies;
};
/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */