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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user