// Is this external tool on this machine, and which version? — the one probe. // // Plain ESM (no app imports) so umtool's `lib/tools.mjs`, which `umtool doctor` // runs under bare node, and `archilyzer doctor` probe a binary the SAME way: a // doctor that asked a different question than its twin would be worse than no // doctor. Each caller keeps its own TABLE of tools (what it needs, and why); // only the asking lives here. // // READ-ONLY by construction: a probe runs the tool's version flag (or stats a // file) and nothing else. Never call it from a page render — it forks. import { execFile } from "node:child_process"; import { stat } from "node:fs/promises"; import { promisify } from "node:util"; const execFileP = promisify(execFile); /** * @typedef {{ id: string, bin?: string, file?: string, args?: string[], fallback?: string, neededBy: string[], required?: boolean }} ToolSpec * @typedef {{ id: string, bin: string, present: boolean, version: string | null, error: string | null, neededBy: string[], required: boolean }} ToolReport */ const firstLine = (/** @type {unknown} */ s) => String(s ?? "").split("\n").find((l) => l.trim()) ?? ""; /** * `ffmpeg version 7.1.1 …` -> `7.1.1`; `2025.08.11` -> itself. * @param {unknown} text * @returns {string | null} */ export function versionOf(text) { const line = firstLine(text); const m = line.match(/(\d+\.\d+(?:\.\d+)*(?:[-_.][A-Za-z0-9]+)*)/); return m ? m[1] : line.slice(0, 60) || null; } /** * Probe one tool: run `bin args` (5 s cap), or stat `file` when the spec names a * file instead of a binary. ENOENT is "not on this machine"; any other exit * means the binary RAN — an unusual version flag, say — which is presence, * honestly reported. A `fallback` (ImageMagick 6's `convert` for 7's `magick`) * is reported as absent-with-a-reason, because the pipeline calls `bin`. * `env` is the environment the tool runs under (its PATH decides which binary a * bare name finds); default: this process's. * @param {ToolSpec} t * @param {{ env?: NodeJS.ProcessEnv }} [opts] * @returns {Promise} */ export async function probeTool(t, opts = {}) { const base = { id: t.id, bin: t.file ?? t.bin ?? "", neededBy: t.neededBy, required: !!t.required, }; if (t.file) { const ok = await stat(t.file).then( (s) => s.isFile(), () => false, ); return { ...base, present: ok, version: null, error: ok ? null : `${t.file} is missing` }; } const args = t.args ?? ["--version"]; /** @param {string} bin */ const run = async (bin) => { try { const { stdout, stderr } = await execFileP(bin, args, { timeout: 5000, maxBuffer: 1 << 20, ...(opts.env ? { env: opts.env } : {}), }); return { present: true, version: versionOf(stdout || stderr), error: null }; } catch (/** @type {any} */ err) { if (err?.code === "ENOENT") return { present: false, version: null, error: `${bin}: not found` }; const said = versionOf(err?.stdout || err?.stderr); return { present: true, version: said || null, error: said ? null : `${bin} exited ${err?.code ?? "?"} on ${args.join(" ")}`, }; } }; let r = await run(/** @type {string} */ (t.bin)); if (!r.present && t.fallback) { const f = await run(t.fallback); if (f.present) { r = { present: false, version: f.version, error: `only \`${t.fallback}\` is installed (ImageMagick 6); the pipeline calls \`${t.bin}\``, }; } } return { ...base, ...r }; }