|
| 1 | +// Installer for the tools that only ship through a system package manager. |
| 2 | +// |
| 3 | +// ffmpeg and ImageMagick are the odd ones in TOOLS: they are not a vendor's CLI |
| 4 | +// with a `curl … | sh` of its own, and they are not a static binary on a GitHub |
| 5 | +// release either. They are distro packages, which is why they are installed the |
| 6 | +// way a distro package is installed — and why this is a separate file from |
| 7 | +// release-install.mjs rather than another descriptor in it. |
| 8 | +// |
| 9 | +// Static builds do exist for both. They are third-party redistributions of |
| 10 | +// somebody else's codec stack, unsigned, and updated by nobody in particular. |
| 11 | +// Downloading one to avoid a sudo prompt would be trading a password for a |
| 12 | +// binary we cannot vouch for, on the two tools most likely to be pointed at a |
| 13 | +// file from the internet. |
| 14 | +// |
| 15 | +// Everything that decides *what to run* is a pure function so the per-manager |
| 16 | +// argv (which differ in irritating ways) is unit-tested offline; the only |
| 17 | +// impure part is the loop at the bottom that runs it. |
| 18 | +import { spawnSync } from "node:child_process"; |
| 19 | +import { realpathSync } from "node:fs"; |
| 20 | +import { fileURLToPath } from "node:url"; |
| 21 | + |
| 22 | +import { findEscalator } from "./escalate.mjs"; |
| 23 | + |
| 24 | +/** |
| 25 | + * How each manager installs, non-interactively. |
| 26 | + * |
| 27 | + * Non-interactive is the point: this runs inside `moshcode install` and inside |
| 28 | + * `moshcode upgrade tools`, and a manager that stops to ask "Do you want to |
| 29 | + * continue? [Y/n]" inside an upgrade sweep parks the whole plan. |
| 30 | + * |
| 31 | + * apt refreshes first because its index goes stale on its own: a box that has |
| 32 | + * not run `apt-get update` in a few months gets a 404 on the archive rather |
| 33 | + * than a package, and the error names a URL instead of the actual problem. |
| 34 | + */ |
| 35 | +export const MANAGERS = { |
| 36 | + brew: { |
| 37 | + // Never escalated. Homebrew refuses to run as root and says so at length. |
| 38 | + root: false, |
| 39 | + steps: (pkg) => [["brew", ["install", pkg]]], |
| 40 | + }, |
| 41 | + "apt-get": { |
| 42 | + root: true, |
| 43 | + steps: (pkg) => [ |
| 44 | + ["apt-get", ["update", "-qq"]], |
| 45 | + ["apt-get", ["install", "-y", "--no-install-recommends", pkg]], |
| 46 | + ], |
| 47 | + }, |
| 48 | + dnf: { root: true, steps: (pkg) => [["dnf", ["install", "-y", pkg]]] }, |
| 49 | + zypper: { root: true, steps: (pkg) => [["zypper", ["--non-interactive", "install", pkg]]] }, |
| 50 | + pacman: { root: true, steps: (pkg) => [["pacman", ["-S", "--needed", "--noconfirm", pkg]]] }, |
| 51 | + apk: { root: true, steps: (pkg) => [["apk", ["add", "--no-cache", pkg]]] }, |
| 52 | +}; |
| 53 | + |
| 54 | +/** The order managers are probed in. brew first, and only because of macOS. */ |
| 55 | +export const MANAGER_ORDER = ["brew", "apt-get", "dnf", "zypper", "pacman", "apk"]; |
| 56 | + |
| 57 | +/** |
| 58 | + * Package names per tool, per manager, in the order they are worth trying. |
| 59 | + * |
| 60 | + * Two entries have more than one name and both are facts about somebody else's |
| 61 | + * archive rather than hedging: |
| 62 | + * |
| 63 | + * Fedora ships `ffmpeg-free` in the main repositories and the full `ffmpeg` |
| 64 | + * only from RPM Fusion, so a box without that repo enabled has exactly one of |
| 65 | + * the two names and `dnf install ffmpeg` fails outright on it. |
| 66 | + * |
| 67 | + * `imagemagick` is one name for two different programs: on Ubuntu up to |
| 68 | + * 24.04 it depends on the 6.x package and puts `convert` on PATH, and from |
| 69 | + * 25.04 it depends on the 7.x one and puts `magick` there instead. The |
| 70 | + * package name is stable, which is why this table has one entry and the |
| 71 | + * tool's `bin` has two. |
| 72 | + */ |
| 73 | +export const PACKAGES = { |
| 74 | + ffmpeg: { |
| 75 | + brew: ["ffmpeg"], |
| 76 | + "apt-get": ["ffmpeg"], |
| 77 | + dnf: ["ffmpeg", "ffmpeg-free"], |
| 78 | + zypper: ["ffmpeg"], |
| 79 | + pacman: ["ffmpeg"], |
| 80 | + apk: ["ffmpeg"], |
| 81 | + }, |
| 82 | + imagemagick: { |
| 83 | + brew: ["imagemagick"], |
| 84 | + "apt-get": ["imagemagick"], |
| 85 | + dnf: ["ImageMagick"], |
| 86 | + zypper: ["ImageMagick"], |
| 87 | + pacman: ["imagemagick"], |
| 88 | + apk: ["imagemagick"], |
| 89 | + }, |
| 90 | +}; |
| 91 | + |
| 92 | +function defaultProbe(tool) { |
| 93 | + return spawnSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }).status === 0; |
| 94 | +} |
| 95 | + |
| 96 | +/** Resolve a name to its package table, or throw. Own properties only. */ |
| 97 | +export function resolvePackage(tool) { |
| 98 | + const key = String(tool ?? "").trim().toLowerCase(); |
| 99 | + if (!Object.hasOwn(PACKAGES, key)) { |
| 100 | + throw new Error( |
| 101 | + `unknown package ${JSON.stringify(tool)} — expected one of ${Object.keys(PACKAGES).join(", ")}`, |
| 102 | + ); |
| 103 | + } |
| 104 | + return [key, PACKAGES[key]]; |
| 105 | +} |
| 106 | + |
| 107 | +/** Which package manager this machine has, or null. */ |
| 108 | +export function findManager({ probe = defaultProbe, order = MANAGER_ORDER } = {}) { |
| 109 | + for (const name of order) { |
| 110 | + if (probe(name)) return name; |
| 111 | + } |
| 112 | + return null; |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * The commands that install one package name with one manager. |
| 117 | + * |
| 118 | + * Escalation is applied here rather than by the caller because whether a step |
| 119 | + * needs it is a property of the manager: brew must not be escalated, the rest |
| 120 | + * must be unless we are already root. A `null` escalator on a manager that |
| 121 | + * needs one yields the bare command, which fails with the manager's own |
| 122 | + * permission message — better advice than anything we would write. |
| 123 | + */ |
| 124 | +export function installSteps(manager, pkg, { escalator = null, isRoot = false } = {}) { |
| 125 | + const spec = MANAGERS[manager]; |
| 126 | + if (!spec) throw new Error(`unknown package manager ${JSON.stringify(manager)}`); |
| 127 | + const escalate = spec.root && !isRoot && escalator; |
| 128 | + return spec.steps(pkg).map(([cmd, args]) => |
| 129 | + escalate ? { cmd: escalator, args: [cmd, ...args] } : { cmd, args }, |
| 130 | + ); |
| 131 | +} |
| 132 | + |
| 133 | +/** |
| 134 | + * Install a tool through whichever package manager is here. |
| 135 | + * |
| 136 | + * Package names are tried in order and the first that installs wins, because a |
| 137 | + * name that is absent from this box's archive is a normal outcome (see the |
| 138 | + * Fedora note above) rather than a failure to report. Only when every candidate |
| 139 | + * has failed is there something to say. |
| 140 | + */ |
| 141 | +export function installPackage(tool, { run = spawnSync, probe = defaultProbe, log = console.log } = {}) { |
| 142 | + const [key, table] = resolvePackage(tool); |
| 143 | + const manager = findManager({ probe }); |
| 144 | + if (!manager) { |
| 145 | + throw new Error( |
| 146 | + `no supported package manager found (${MANAGER_ORDER.join(", ")}) — install ${key} yourself and re-run`, |
| 147 | + ); |
| 148 | + } |
| 149 | + |
| 150 | + const candidates = table[manager]; |
| 151 | + if (!candidates?.length) { |
| 152 | + throw new Error(`${key} has no known package name for ${manager} — install it yourself and re-run`); |
| 153 | + } |
| 154 | + |
| 155 | + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; |
| 156 | + const escalator = MANAGERS[manager].root && !isRoot ? findEscalator({ probe }) : null; |
| 157 | + |
| 158 | + const failures = []; |
| 159 | + for (const pkg of candidates) { |
| 160 | + log(`↓ ${manager} ${pkg}`); |
| 161 | + let ok = true; |
| 162 | + for (const step of installSteps(manager, pkg, { escalator, isRoot })) { |
| 163 | + const result = run(step.cmd, step.args, { stdio: "inherit" }); |
| 164 | + if (result?.error || result?.status !== 0) { |
| 165 | + failures.push(`${pkg}: ${step.cmd} ${step.args.join(" ")} ${result?.error ? `(${result.error.message})` : `exited ${result?.status}`}`); |
| 166 | + ok = false; |
| 167 | + break; |
| 168 | + } |
| 169 | + } |
| 170 | + if (ok) { |
| 171 | + log(`✓ ${key} installed with ${manager}`); |
| 172 | + return { manager, pkg }; |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + throw new Error(`could not install ${key} with ${manager}:\n ${failures.join("\n ")}`); |
| 177 | +} |
| 178 | + |
| 179 | +/** True when this file was executed directly rather than imported. */ |
| 180 | +function invokedDirectly() { |
| 181 | + try { |
| 182 | + return realpathSync(process.argv[1] || "") === realpathSync(fileURLToPath(import.meta.url)); |
| 183 | + } catch { |
| 184 | + return false; |
| 185 | + } |
| 186 | +} |
| 187 | + |
| 188 | +if (invokedDirectly()) { |
| 189 | + try { |
| 190 | + installPackage(process.argv[2]); |
| 191 | + } catch (e) { |
| 192 | + console.error(`install failed: ${e.message}`); |
| 193 | + process.exit(1); |
| 194 | + } |
| 195 | +} |
0 commit comments