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 ` * 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 { 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); }