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:
@@ -0,0 +1,219 @@
|
||||
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type OsDependency, type PackageManager } from './manifest';
|
||||
|
||||
// Host binaries a plugin needs, and getting them onto the machine.
|
||||
//
|
||||
// ── Why this exists ──
|
||||
//
|
||||
// Offscale was self-sufficient, so nothing here was missing until music. Music shells out to `ffmpeg` and
|
||||
// `ffprobe`, and without them its indexer does not fail: it walks the whole library, writes a complete
|
||||
// cache tree, reports success, and produces a library where every track is a bare filename with no cover.
|
||||
// Five swallowed catches, no log, no counter. The only tell is `coversSaved: 0` in a report nobody reads.
|
||||
//
|
||||
// That is the failure shape this codebase keeps finding — a refusal wearing the costume of a normal
|
||||
// result — and it is why the answer is a CHECK rather than a note in a README. The owner is told what is
|
||||
// missing before they install, and an install that cannot satisfy a dependency refuses instead of
|
||||
// producing that library.
|
||||
//
|
||||
// ── The one rule about running as root ──
|
||||
//
|
||||
// Package names from a manifest reach `apt-get install` as root. They are validated by a deliberately
|
||||
// narrow regex in manifest.ts and passed to `Bun.spawn` as an ARGV ARRAY — never a shell string, never
|
||||
// interpolated, never `sh -c`. Both halves are load-bearing: the regex means a metacharacter cannot get
|
||||
// here, and argv means it would be an argument rather than syntax if it did.
|
||||
//
|
||||
// Nothing is installed without the owner asking for it. This module is reached from the plugins API,
|
||||
// which is owner-only in its own right, and the missing packages are shown before the button is pressed.
|
||||
|
||||
/**
|
||||
* Which package manager this machine has.
|
||||
*
|
||||
* Mirrors `detect_os` in `scripts/setup/machine-setup/lib/base.sh`, and deliberately by the same means —
|
||||
* looking for the binary rather than reading `/etc/os-release` — because that is the question actually
|
||||
* being asked. A Fedora derivative with apt bolted on should get apt.
|
||||
*
|
||||
* Order matters on exactly one machine shape: Homebrew on Linux. `brew` last means a Linux box with
|
||||
* Linuxbrew still uses its distribution's manager, which is what somebody running a system service
|
||||
* wants.
|
||||
*/
|
||||
let cachedManager: PackageManager | null | undefined;
|
||||
|
||||
export function detectPackageManager(): PackageManager | null {
|
||||
if (cachedManager !== undefined) return cachedManager;
|
||||
cachedManager = PACKAGE_MANAGERS.find((pm) => !!Bun.which(pm)) ?? null;
|
||||
return cachedManager;
|
||||
}
|
||||
|
||||
/** Reset the memoised lookup. Tests only — a real machine does not gain a package manager while running. */
|
||||
export function resetPackageManagerCache(): void {
|
||||
cachedManager = undefined;
|
||||
}
|
||||
|
||||
export type DependencyStatus = {
|
||||
binary: string;
|
||||
reason: string;
|
||||
/** Absolute path when it is on PATH, null when it is not. The whole question, answered per binary. */
|
||||
path: string | null;
|
||||
present: boolean;
|
||||
/** What would be installed on THIS machine, or null when this manager has no entry for it. */
|
||||
packageName: string | null;
|
||||
};
|
||||
|
||||
/** Probe one dependency against the live PATH. */
|
||||
function probe(dep: OsDependency, manager: PackageManager | null): DependencyStatus {
|
||||
const path = Bun.which(dep.binary);
|
||||
return {
|
||||
binary: dep.binary,
|
||||
reason: dep.reason,
|
||||
path,
|
||||
present: !!path,
|
||||
packageName: (manager && dep.packages[manager]) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export type DependencyReport = {
|
||||
manager: PackageManager | null;
|
||||
dependencies: DependencyStatus[];
|
||||
missing: DependencyStatus[];
|
||||
/** Distinct packages to install — deduped, because one package usually provides several binaries. */
|
||||
packagesToInstall: string[];
|
||||
/**
|
||||
* Missing, and this machine has no way to install them: no package manager, or no entry for the one it
|
||||
* has. These are the owner's to resolve by hand, and the install says so rather than guessing.
|
||||
*/
|
||||
unresolvable: DependencyStatus[];
|
||||
/** Can the platform actually run the install — passwordless sudo, or a manager that never needs it. */
|
||||
canInstall: boolean;
|
||||
/** Why not, when it cannot. Written to be shown to a person. */
|
||||
blockedReason: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the platform may install packages without a human at a terminal.
|
||||
*
|
||||
* `sudo -n` is "non-interactive": it succeeds only when a password would not be asked for, and fails
|
||||
* immediately otherwise rather than blocking on a prompt no one can answer. The server runs unattended
|
||||
* under PM2, so a prompt is not a slow path — it is a hang.
|
||||
*
|
||||
* Homebrew is the exception and needs no escalation: it refuses to run as root outright, which is also
|
||||
* why machine-setup never escalates on macOS.
|
||||
*/
|
||||
function sudoAvailable(): boolean {
|
||||
if (!Bun.which('sudo')) return false;
|
||||
try {
|
||||
return Bun.spawnSync(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' }).exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the UI and the installer need to know about one plugin's host dependencies. */
|
||||
export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||
const declared = plugin.manifest.osDependencies ?? [];
|
||||
const manager = detectPackageManager();
|
||||
const dependencies = declared.map((dep) => probe(dep, manager));
|
||||
const missing = dependencies.filter((d) => !d.present);
|
||||
const unresolvable = missing.filter((d) => !d.packageName);
|
||||
|
||||
// Deduped and sorted: ffmpeg and ffprobe are one apt package, and naming it twice on the command line
|
||||
// is the sort of thing that reads as a bug in a log even though apt does not care.
|
||||
const packagesToInstall = [...new Set(missing.map((d) => d.packageName).filter((n): n is string => !!n))].sort();
|
||||
|
||||
let canInstall = true;
|
||||
let blockedReason: string | null = null;
|
||||
|
||||
if (missing.length === 0) {
|
||||
// Nothing to do, so nothing can block it. Said explicitly because the checks below would otherwise
|
||||
// report "no package manager" on a machine that needs nothing — true, and useless.
|
||||
} else if (!manager) {
|
||||
canInstall = false;
|
||||
blockedReason = `No supported package manager found (${PACKAGE_MANAGERS.join(', ')}).`;
|
||||
} else if (unresolvable.length) {
|
||||
canInstall = false;
|
||||
blockedReason =
|
||||
`${unresolvable.map((d) => d.binary).join(', ')} — this plugin declares no ${manager} package for ` +
|
||||
`${unresolvable.length === 1 ? 'it' : 'them'}.`;
|
||||
} else if (manager !== 'brew' && !sudoAvailable()) {
|
||||
canInstall = false;
|
||||
blockedReason =
|
||||
'Officer cannot escalate: passwordless sudo is not configured for the user it runs as. ' +
|
||||
'Install the packages yourself and then install the plugin.';
|
||||
}
|
||||
|
||||
return { manager, dependencies, missing, packagesToInstall, unresolvable, canInstall, blockedReason };
|
||||
}
|
||||
|
||||
/** The command that would be run, as argv. Exported so the UI can SHOW it — the owner should see it. */
|
||||
export function installCommand(manager: PackageManager, packages: string[]): string[] {
|
||||
const escalate = manager === 'brew' ? [] : ['sudo', '-n'];
|
||||
switch (manager) {
|
||||
// Matches lib/packages.sh's `pkg_install_now` exactly, including the environment apt needs to stay
|
||||
// non-interactive. A second spelling of "install a package" that drifted from the setup script's
|
||||
// would be a bug nobody finds until a machine behaves differently from the one it was set up on.
|
||||
case 'apt':
|
||||
return [
|
||||
...escalate,
|
||||
'env',
|
||||
'DEBIAN_FRONTEND=noninteractive',
|
||||
'NEEDRESTART_MODE=a',
|
||||
'apt-get',
|
||||
'install',
|
||||
'-y',
|
||||
...packages,
|
||||
];
|
||||
case 'pacman':
|
||||
return [...escalate, 'pacman', '-S', '--noconfirm', '--needed', ...packages];
|
||||
case 'dnf':
|
||||
return [...escalate, 'dnf', 'install', '-y', ...packages];
|
||||
case 'brew':
|
||||
return ['brew', 'install', ...packages];
|
||||
}
|
||||
}
|
||||
|
||||
/** What the owner should run by hand when the platform cannot. Display only — never executed. */
|
||||
export function manualInstallHint(manager: PackageManager | null, packages: string[]): string | null {
|
||||
if (!manager || !packages.length) return null;
|
||||
const argv = installCommand(manager, packages).filter((a) => a !== '-n');
|
||||
return argv.join(' ');
|
||||
}
|
||||
|
||||
export type InstallDependenciesResult = { ok: boolean; installed: string[]; output: string; error?: string };
|
||||
|
||||
/**
|
||||
* Install the missing packages.
|
||||
*
|
||||
* Only ever called with `report.packagesToInstall`, so a binary already on PATH is never named on a
|
||||
* command line — `packages.sh`'s rule, and it matters for the same reason: `apt-get install <present>`
|
||||
* is not a no-op, it upgrades. A plugin install must not move a version the owner chose.
|
||||
*/
|
||||
export async function installDependencies(
|
||||
manager: PackageManager,
|
||||
packages: string[],
|
||||
): Promise<InstallDependenciesResult> {
|
||||
if (!packages.length) return { ok: true, installed: [], output: '' };
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(installCommand(manager, packages), { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
const output = `${out}${err}`.trim().slice(-600);
|
||||
if (code !== 0) return { ok: false, installed: [], output, error: output || `exited ${code}` };
|
||||
return { ok: true, installed: packages, output };
|
||||
} catch (err) {
|
||||
return { ok: false, installed: [], output: '', error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-probe after installing, so success is observed rather than inferred from an exit code.
|
||||
*
|
||||
* `apt-get` returning 0 having installed a package that does not provide the binary the plugin actually
|
||||
* spawns is precisely the sort of thing this whole module exists to catch, and trusting the exit code
|
||||
* would reproduce it one layer up.
|
||||
*/
|
||||
export function stillMissing(plugin: DiscoveredPlugin): string[] {
|
||||
const manager = detectPackageManager();
|
||||
return (plugin.manifest.osDependencies ?? [])
|
||||
.map((dep) => probe(dep, manager))
|
||||
.filter((d) => !d.present)
|
||||
.map((d) => d.binary);
|
||||
}
|
||||
Reference in New Issue
Block a user