/** * System prerequisite detection. * * Single source of truth for "what tools must be on the management * server's PATH for celilo (and the modules it manages) to function." * See `apps/celilo/designs/PREREQ_DETECTION.md` for the full design, * including the layered platform contract for module developers. * * The PREREQUISITES table here is the ONLY place a tool gets added to * the platform contract. `system doctor`, `system init`, and the * runtime invokers (ansible/terraform shell-outs) all consume it from * here. * * All entries are universally DECLARED — no required-vs-recommended * distinction. The platform is the platform; an operator who currently * doesn't use Terraform still sees a terraform row, on the theory that * predictability beats marginal install-friction savings. Declaring is not * the same as installing: terraform and the browser are both installed by * `celilo-mgmt` only on request, and both report here either way, which is * how an operator finds out a host is missing one. */ import { spawnSync } from 'node:child_process'; import { constants, accessSync, statSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { BROWSER_EXECUTABLE_PATH } from '@celilo/capabilities'; // ── Types ───────────────────────────────────────────────────────────── /** * Package managers we can produce install hints for. `none` means * we couldn't detect a recognized package manager (rare — * default-link-to-docs fallback). */ export type PackageManager = 'apt' | 'dnf' | 'yum' | 'pacman' | 'apk' | 'brew' | 'none'; /** * Static description of a prerequisite. Lives in the PREREQUISITES * table below; consumers don't construct these themselves. */ export interface PrerequisiteSpec { /** Row label in doctor output, and the binary name when `command` is * absent (e.g. 'ansible'). */ name: string; /** One-line "what this is for" text used in doctor output. */ description: string; /** What to execute, when that differs from `name`. An ABSOLUTE path * switches the presence check from `command -v` to "this exact file * exists and is executable" — the celilo-provisioned browser lives at * a known path and is never on PATH. */ command?: string; /** Argument that makes the tool report what the row displays * (`--version` for most; `version` for terraform; `-V` for ssh; * `-v` for unzip; a font pattern for fc-match). */ versionFlag: string; /** Captures the displayed string from that output. First capture group * must be the bare value (e.g. `2.16.3`). */ versionRegex: RegExp; /** Minimum semver. `null` means presence-only — any version OK. */ minVersion: string | null; } /** * Result of checking one prerequisite. Non-throwing; failure modes * surface via `present: false` or `meetsMinimum: false`. */ export interface PrereqCheck { name: string; description: string; /** The tool was found — on PATH, or at its declared absolute path. */ present: boolean; /** Resolved executable (or null when absent). */ binaryPath: string | null; /** Version captured from `--version` output, or null on * parse-fail / not-present. */ version: string | null; /** True when the spec has no minimum, OR the captured version * satisfies it. False when missing, parse-failed-with-minimum, * or below-minimum. */ meetsMinimum: boolean; /** OS-aware install instruction (always populated; falls back to * generic guidance when the package manager isn't recognized). */ installHint: string; } // ── PREREQUISITES table ─────────────────────────────────────────────── /** * The platform contract's "operator-installed tools" layer. Edit this * list to add/remove tools from the contract. Per-OS install hints * live in INSTALL_HINTS below. */ export const PREREQUISITES: PrerequisiteSpec[] = [ { name: 'bun', description: 'Celilo runtime', versionFlag: '--version', versionRegex: /(\d+\.\d+\.\d+)/, minVersion: null, }, { name: 'ansible', description: 'Configures hosts via playbooks', versionFlag: '--version', // Modern ansible: "ansible [core 2.16.3]"; older: "ansible 2.9.27". // Pick the first X.Y.Z triple in the output; both shapes work. versionRegex: /(\d+\.\d+\.\d+)/, minVersion: '2.15.0', }, { name: 'ansible-galaxy', description: 'Installs Ansible collection deps', versionFlag: '--version', versionRegex: /(\d+\.\d+\.\d+)/, minVersion: '2.15.0', }, { name: 'terraform', description: 'Provisions container infrastructure', // terraform's flag is `version` (no leading --), unique among our prereqs. versionFlag: 'version', versionRegex: /Terraform\s+v(\d+\.\d+\.\d+)/, minVersion: '1.6.0', }, { name: 'ssh', description: 'Reaches managed machines', // ssh -V prints to stderr, e.g. "OpenSSH_9.6p1, LibreSSL 3.3.6". // checkPrerequisite captures both streams so this Just Works. versionFlag: '-V', versionRegex: /OpenSSH[_\s]+(\d+\.\d+)/, minVersion: null, }, { name: 'git', description: 'Stale-version checks; module build scripts', versionFlag: '--version', versionRegex: /git version (\d+\.\d+\.\d+)/, minVersion: null, }, { name: 'curl', description: 'HTTP fetches in install.sh and module hooks', versionFlag: '--version', versionRegex: /curl\s+(\d+\.\d+\.\d+)/, minVersion: null, }, { name: 'unzip', description: "Used by Bun's installer (bootstrap-only)", versionFlag: '-v', versionRegex: /UnZip\s+(\d+\.\d+)/, minVersion: null, }, // Declared unconditionally even though installation is opt-in — same as // terraform above. `system doctor` is the ONLY place an operator learns // this host has no browser, because a not-provisioned browser is // deliberately not a check failure. // // The check probes the PROVISIONED PATH and must never ask Playwright // which executable it would use: `chromium.executablePath()` reports the // FULL browser while a headless launch opens the SHELL — measured // disagreeing on one machine in one run. On a shell-only host that would // validate a path which does not exist while the binary that actually // runs is fine. Running it is also what separates a real install from a // build directory with no binary in it, which satisfies a path test and // then fails at launch. { name: 'browser', description: 'Runs browser-driven module health checks', command: BROWSER_EXECUTABLE_PATH, versionFlag: '--version', // e.g. "Chromium 148.0.7778.0" versionRegex: /(\d+\.\d+\.\d+(?:\.\d+)?)/, minVersion: null, }, // Fonts belong here rather than in the record consumers read: the party // who needs to know a retained screenshot has legible glyphs is the // operator looking at it. `fc-match sans-serif` resolves an actual face, // so it answers "is there a text font" rather than "is fontconfig // installed", and the family name is what the row displays. { name: 'fonts', description: 'Legible text in browser screenshots', command: 'fc-match', versionFlag: 'sans-serif', // e.g. 'DejaVuSans.ttf: "DejaVu Sans" "Book"' versionRegex: /"([^"]+)"/, minVersion: null, }, ]; // ── Per-OS install hints ────────────────────────────────────────────── /** * Per-tool, per-package-manager install commands. A missing entry * falls back to FALLBACK_HINTS (for tools with non-standard install * paths like bun and terraform) or to a generic guidance string. */ const INSTALL_HINTS: Record>> = { bun: { pacman: 'sudo pacman -S bun', brew: 'brew install oven-sh/bun/bun', // apt/dnf/yum/apk: no native package; fall through to FALLBACK_HINTS. }, ansible: { apt: 'sudo apt-get install ansible', dnf: 'sudo dnf install ansible', yum: 'sudo yum install ansible', pacman: 'sudo pacman -S ansible', apk: 'sudo apk add ansible', brew: 'brew install ansible', }, 'ansible-galaxy': { // ansible-galaxy ships in the same tarball as ansible. If the // operator is missing it, the fix is "install ansible." Phrase the // hint so the operator's eye lands on the ansible row, not on this // one. apt: '(installed alongside ansible — install ansible)', dnf: '(installed alongside ansible — install ansible)', yum: '(installed alongside ansible — install ansible)', pacman: '(installed alongside ansible — install ansible)', apk: '(installed alongside ansible — install ansible)', brew: '(installed alongside ansible — install ansible)', }, terraform: { // The HashiCorp apt/dnf/yum repos require a multi-step GPG-key + // apt-source setup that's too long for a single-line hint. // Link to HashiCorp's docs and let the operator follow the recipe. apt: 'See https://developer.hashicorp.com/terraform/install', dnf: 'See https://developer.hashicorp.com/terraform/install', yum: 'See https://developer.hashicorp.com/terraform/install', pacman: 'sudo pacman -S terraform', apk: 'sudo apk add terraform', brew: 'brew install terraform', }, ssh: { apt: 'sudo apt-get install openssh-client', dnf: 'sudo dnf install openssh-clients', yum: 'sudo yum install openssh-clients', pacman: 'sudo pacman -S openssh', apk: 'sudo apk add openssh-client', // macOS ships /usr/bin/ssh built-in; missing it means a busted // system, not a missing brew formula. Fall through. }, git: { apt: 'sudo apt-get install git', dnf: 'sudo dnf install git', yum: 'sudo yum install git', pacman: 'sudo pacman -S git', apk: 'sudo apk add git', brew: 'brew install git', }, curl: { apt: 'sudo apt-get install curl', dnf: 'sudo dnf install curl', yum: 'sudo yum install curl', pacman: 'sudo pacman -S curl', apk: 'sudo apk add curl', // macOS ships /usr/bin/curl built-in. }, fonts: { apt: 'sudo apt-get install fontconfig fonts-dejavu-core', dnf: 'sudo dnf install fontconfig dejavu-sans-fonts', yum: 'sudo yum install fontconfig dejavu-sans-fonts', pacman: 'sudo pacman -S fontconfig ttf-dejavu', apk: 'sudo apk add fontconfig font-dejavu', brew: 'brew install fontconfig', }, unzip: { apt: 'sudo apt-get install unzip', dnf: 'sudo dnf install unzip', yum: 'sudo yum install unzip', pacman: 'sudo pacman -S unzip', apk: 'sudo apk add unzip', // macOS ships /usr/bin/unzip built-in. }, }; /** * Tools whose canonical install path isn't a system package manager. * Used when no package-manager-specific hint is available. */ const FALLBACK_HINTS: Record = { bun: 'See https://bun.sh/install', terraform: 'See https://developer.hashicorp.com/terraform/install', // Not a package: celilo installs it itself, on request. browser: 'celilo module config set celilo-mgmt install_browser true && celilo module deploy celilo-mgmt', }; // ── Detection ───────────────────────────────────────────────────────── /** * Detect the operator's primary package manager. * * On Darwin, brew wins if installed (otherwise we report 'none' and * the install hints fall back to docs links). On Linux/other Unix, * we probe for apt-get → dnf → yum → pacman → apk in priority order. * * The ordering matters: a Debian-derived box that has both apt-get * and (somehow) dnf still gets 'apt'. Distros with multiple package * managers in PATH are rare in operator scenarios. */ export function detectPackageManager(): PackageManager { if (process.platform === 'darwin') { return Bun.which('brew') ? 'brew' : 'none'; } if (Bun.which('apt-get')) return 'apt'; if (Bun.which('dnf')) return 'dnf'; if (Bun.which('yum')) return 'yum'; if (Bun.which('pacman')) return 'pacman'; if (Bun.which('apk')) return 'apk'; return 'none'; } /** * Look up the OS-appropriate install command for a tool. Always * returns a non-empty string — falls through to docs links and * generic guidance when there's no exact match. */ export function getInstallHint(toolName: string, pm: PackageManager): string { const pmHint = INSTALL_HINTS[toolName]?.[pm]; if (pmHint) return pmHint; const fallback = FALLBACK_HINTS[toolName]; if (fallback) return fallback; return `Install '${toolName}' via your package manager`; } /** * Compare two semver triples (MAJOR.MINOR.PATCH). Missing components * default to 0 so '2.16' compares as '2.16.0'. Returns -1 / 0 / 1. * * Exported for testing. */ export function compareVersions(a: string, b: string): number { const aParts = a.split('.').map((n) => Number.parseInt(n, 10) || 0); const bParts = b.split('.').map((n) => Number.parseInt(n, 10) || 0); const len = Math.max(aParts.length, bParts.length); for (let i = 0; i < len; i++) { const av = aParts[i] ?? 0; const bv = bParts[i] ?? 0; if (av < bv) return -1; if (av > bv) return 1; } return 0; } /** * Run a single prerequisite check. Two-phase: * 1. Presence: `Bun.which(name)` (POSIX `command -v` semantics). * 2. Version: spawn ` ` and regex-match the output. * Both stdout and stderr are scanned because some tools * (notably ssh -V) write the version banner to stderr. * * Errors are non-throwing: any failure short-circuits to a * `present: false` or `meetsMinimum: false` result. Callers decide * what to do with that. * * Per-call timeout: 5s. A version probe that hangs longer than that * is a misbehaving binary or a stuck PATH lookup; either way we * shouldn't wedge the doctor command waiting on it. */ /** * Resolve an absolute-path prerequisite. Returns the path only when it is * a real file that is executable — a symlink pointing at nothing, or a * build directory with no binary in it, resolves to null. */ function executableAt(path: string): string | null { try { // statSync follows symlinks, so a dangling link throws here. if (!statSync(path).isFile()) return null; accessSync(path, constants.X_OK); return path; } catch { return null; } } export function checkPrerequisite( spec: PrerequisiteSpec, pm: PackageManager = detectPackageManager(), ): PrereqCheck { const installHint = getInstallHint(spec.name, pm); const command = spec.command ?? spec.name; const binaryPath = isAbsolute(command) ? executableAt(command) : Bun.which(command); if (!binaryPath) { return { name: spec.name, description: spec.description, present: false, binaryPath: null, version: null, meetsMinimum: false, installHint, }; } let version: string | null = null; try { const result = spawnSync(binaryPath, [spec.versionFlag], { encoding: 'utf-8', timeout: 5000, // Some tools (notably git on macOS) refuse to run with a // cleared HOME or empty env; inherit the parent process env so // we get the same PATH the runtime invokers will use later // (PATH-gotcha smoke-test from the design doc). env: process.env, }); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; const match = output.match(spec.versionRegex); version = match?.[1] ?? null; } catch { // spawnSync threw — broken binary, denied permissions, etc. // Treat as present-but-version-unknown. version = null; } const meetsMinimum = (() => { if (!spec.minVersion) return true; // no minimum → always passes if (!version) return false; // had a minimum but couldn't read version return compareVersions(version, spec.minVersion) >= 0; })(); return { name: spec.name, description: spec.description, present: true, binaryPath, version, meetsMinimum, installHint, }; } /** * Run every check in the PREREQUISITES table and return the * results in declaration order (which is the order the doctor / * init output renders them — `bun` first, then runtime tools). */ export function checkAllPrerequisites(): PrereqCheck[] { const pm = detectPackageManager(); return PREREQUISITES.map((spec) => checkPrerequisite(spec, pm)); } /** * Convenience: subset of checks that failed (missing or below-min). * The doctor / init / runtime-guard callers all want this filter * before deciding whether to block. */ export function failingPrerequisites(checks: PrereqCheck[]): PrereqCheck[] { return checks.filter((c) => !c.present || !c.meetsMinimum); }