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:
@@ -44,6 +44,46 @@ export type PluginPermission = {
|
||||
readOnlyWrites?: string[];
|
||||
};
|
||||
|
||||
/** The package managers the platform knows how to drive. Mirrors `PM` in machine-setup's lib/base.sh. */
|
||||
export type PackageManager = 'apt' | 'pacman' | 'dnf' | 'brew';
|
||||
|
||||
/**
|
||||
* A host binary the plugin shells out to, and how to get it.
|
||||
*
|
||||
* ── Why a BINARY plus per-manager package names, and not a package name ──
|
||||
*
|
||||
* Offscale needed nothing. Music needs `ffmpeg` and `ffprobe`, and without them its indexer does not
|
||||
* fail — it writes a complete library where every track is a bare filename with no cover, and reports
|
||||
* success. A refusal wearing the costume of a normal result, which is the failure shape this codebase
|
||||
* keeps finding. So a plugin has to be able to say what it needs, and the platform has to check.
|
||||
*
|
||||
* The shape is taken from `scripts/setup-old/setup.sh`, which had already solved this:
|
||||
*
|
||||
* if has <binary>; then skip; else
|
||||
* case $PM in apt) …; pacman) …; brew) skip "built-in" ;; esac
|
||||
* fi
|
||||
*
|
||||
* Probing the BINARY rather than the package is what makes that `brew) built-in` case free: if it is on
|
||||
* PATH we never look at `packages` at all, so a manager needing no package simply has no entry.
|
||||
*
|
||||
* And the names are per manager rather than canonical-with-overrides, deliberately. `packages.sh` says
|
||||
* why, having tried the alternative: `build-essential`/`base-devel`, `fd`/`fd-find`, and some things are
|
||||
* not a package elsewhere at all. A translation table hides both behind indirection; a per-manager map
|
||||
* says what each system actually gets.
|
||||
*/
|
||||
export type OsDependency = {
|
||||
/** Looked up on PATH. The name the plugin's code actually spawns, not a package. */
|
||||
binary: string;
|
||||
/** Shown to the owner before they install. What breaks without it, in their words not a package's. */
|
||||
reason: string;
|
||||
/**
|
||||
* Package to install per manager. A manager left out means "cannot be installed automatically here" —
|
||||
* which for a binary that is present anyway is correct and silent, and otherwise becomes an instruction
|
||||
* to the owner rather than a guess.
|
||||
*/
|
||||
packages: Partial<Record<PackageManager, string>>;
|
||||
};
|
||||
|
||||
export type PluginManifest = {
|
||||
/**
|
||||
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
|
||||
@@ -63,6 +103,15 @@ export type PluginManifest = {
|
||||
color: string;
|
||||
|
||||
permissions: PluginPermission[];
|
||||
|
||||
/**
|
||||
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
|
||||
*
|
||||
* Checked before anything else happens on install: present ones are never named on a command line, and
|
||||
* a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
|
||||
* that runs and quietly produces nothing.
|
||||
*/
|
||||
osDependencies?: OsDependency[];
|
||||
};
|
||||
|
||||
/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */
|
||||
@@ -123,6 +172,65 @@ export function mountPrefix(plugin: { appName: string; manifest: { publisher: st
|
||||
/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */
|
||||
const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
||||
|
||||
/** Every package manager the installer can drive. Anything else in a manifest is refused by name. */
|
||||
export const PACKAGE_MANAGERS: PackageManager[] = ['apt', 'pacman', 'dnf', 'brew'];
|
||||
|
||||
/**
|
||||
* What may appear in an `osDependencies` entry — and this is the strictest rule in the file, on purpose.
|
||||
*
|
||||
* These two strings are the only values in a manifest that reach a root command line. They are passed to
|
||||
* `Bun.spawn` as an argv array and never through a shell, so a metacharacter could not be interpreted
|
||||
* even if one got here — but "could not be interpreted by the shell we happen to use today" is not a
|
||||
* property to rest a root install on. So the characters are not there to be interpreted.
|
||||
*
|
||||
* Deliberately narrower than what package managers actually accept. No `+`, no `~`, no `:` — so `apt`'s
|
||||
* `pkg:arch` qualifier and any version pin are refused. A plugin that needs one wants a conversation,
|
||||
* not a regex that already allows it.
|
||||
*/
|
||||
const BINARY_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
||||
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}$/;
|
||||
|
||||
/** Reasons an `osDependencies` list is unusable. Empty when it is fine. */
|
||||
function osDependencyProblems(deps: unknown): string[] {
|
||||
if (deps === undefined) return [];
|
||||
if (!Array.isArray(deps)) return ['osDependencies must be an array when present'];
|
||||
|
||||
const problems: string[] = [];
|
||||
for (const [i, dep] of deps.entries()) {
|
||||
const at = `osDependencies[${i}]`;
|
||||
if (!dep || typeof dep !== 'object') {
|
||||
problems.push(`${at} must be an object`);
|
||||
continue;
|
||||
}
|
||||
const { binary, reason, packages } = dep as Partial<OsDependency>;
|
||||
|
||||
if (typeof binary !== 'string' || !BINARY_RE.test(binary)) {
|
||||
problems.push(`${at}.binary must be a plain command name — letters, digits, dot, dash, underscore`);
|
||||
}
|
||||
// Required rather than optional: it is what the owner reads when deciding whether to let this
|
||||
// machine install something as root, and "ffprobe" alone does not answer that question.
|
||||
if (typeof reason !== 'string' || !reason.trim()) {
|
||||
problems.push(`${at}.reason is required — say what stops working without it`);
|
||||
}
|
||||
if (!packages || typeof packages !== 'object') {
|
||||
problems.push(`${at}.packages must be an object keyed by package manager`);
|
||||
continue;
|
||||
}
|
||||
for (const [manager, name] of Object.entries(packages)) {
|
||||
if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) {
|
||||
problems.push(
|
||||
`${at}.packages has unknown package manager "${manager}" — one of ${PACKAGE_MANAGERS.join(', ')}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof name !== 'string' || !PACKAGE_RE.test(name)) {
|
||||
problems.push(`${at}.packages.${manager} must be a plain package name — lowercase, digits, dot, plus, dash`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** A publisher shares the app name's constraints — it is a path segment too. */
|
||||
const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
||||
|
||||
@@ -161,5 +269,6 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.push(...osDependencyProblems(manifest.osDependencies));
|
||||
return problems;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user