diff --git a/README.md b/README.md index a1cd752..6a7566b 100644 --- a/README.md +++ b/README.md @@ -762,15 +762,50 @@ newer, and re-running `moshcode install alchemy` is its upgrade path. Where CoinPay is the payments product MoshCode ships alongside, Alchemy is the read side of the same world — the chain itself rather than one wallet's ledger. +### yt-dlp, ffmpeg, ImageMagick — the media toolchain + +```sh +moshcode install yt-dlp # static binary → ~/.local/bin +moshcode install ffmpeg # your distro's package manager (needs sudo) +moshcode install imagemagick # likewise + +moshcode yt-dlp https://… # or `dl https://…` from cli-tools +moshcode ffmpeg -i in.mkv out.mp4 +``` + +The odd three out: not workflow CLIs, but the media toolchain the rest of the +roster is built on. `cli-tools` fronts all three — `dl` for yt-dlp, `vid` for +ffmpeg, `img` for ImageMagick — and every one of them used to answer a missing +binary by telling you to go and install a system package by hand. Now the +registry that installs `cli-tools` installs what it runs on. + +**yt-dlp** comes from its own releases as a self-contained binary, so it needs +no python and no package manager. That is deliberate rather than convenient: +extractors break whenever a site changes its markup, upstream ships a fix within +days, and a distro package of yt-dlp is frozen for the life of a release. Its +upgrade is `yt-dlp -U`, the project's own updater. + +**ffmpeg** and **ImageMagick** exist only as distro packages — no vendor script, +and the static rebuilds floating around are unsigned third-party redistributions +of somebody else's codec stack, on the two tools most likely to be pointed at a +file from the internet. So they go through `apt`/`dnf`/`zypper`/`pacman`/`apk`, +or Homebrew on macOS, and ask for sudo everywhere but a Mac (see below). +Re-running the install upgrades them. + +ImageMagick answers to two names: `magick` on version 7, `convert` on 6, both +current across supported distros under the same package name. MoshCode looks for +either, so a good install is never reported missing. + `gh`, `supabase`, and `doctl` publish no cross-platform install script, so MoshCode resolves the latest GitHub release and drops the binary in `$MOSHCODE_BIN` (default `~/.local/bin`) — no sudo, no package manager. Set `MOSHCODE_BIN` to install elsewhere. -`tailscale` and `spinifex` are the exceptions: both install system services -rather than a user-local binary, so their official installers go through the -distro's package manager and will ask for sudo (tailscale on macOS delegates to -the App Store instead; Spinifex has no macOS build at all). +`tailscale`, `spinifex`, `ffmpeg` and `imagemagick` are the exceptions: none of +them is a user-local binary, so they go through the distro's package manager and +will ask for sudo (tailscale on macOS delegates to the App Store, and `ffmpeg` +and `imagemagick` to Homebrew, which refuses to run as root — so neither is +prompted for a password on a Mac; Spinifex has no macOS build at all). MoshCode asks for that password **before** starting the work rather than letting the installer stop for it partway through — which matters most in `moshcode diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 509e42f..df69ba7 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -10,6 +10,7 @@ import { ENGINES, engineStatus, openSession, + primaryBin, resolveEngine, resolveExecutable, runCmd, @@ -109,13 +110,15 @@ function printStatus(entries, json = false) { console.log(JSON.stringify(entries.map(({ key, desc, bin, installed }) => ({ name: key, description: desc, - binary: bin, + // One name, even for an entry that answers to several: `binary` is a + // documented string in this JSON and something is parsing it. + binary: primaryBin(bin), installed, })), null, 2)); return; } for (const entry of entries) { - console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(10)} ${entry.desc}`); + console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(11)} ${entry.desc}`); } } @@ -375,7 +378,7 @@ async function main() { const r = await openTool(tool, translated.args); if (!r.ok) { console.error(r.error?.code === "ENOENT" - ? `alpaca isn't installed (\`${tool.bin}\`). run: moshcode install alpaca` + ? `alpaca isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install alpaca` : `launch failed: ${r.error?.message || r.error}`); process.exitCode = 1; return; @@ -806,7 +809,7 @@ async function main() { const r = await openTool(tool, rest); if (!r.ok) { console.error(r.error?.code === "ENOENT" - ? `${key} isn't installed (\`${tool.bin}\`). run: moshcode install ${key}` + ? `${key} isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install ${key}` : `launch failed: ${r.error?.message || r.error}`); process.exitCode = 1; return; diff --git a/src/engines.mjs b/src/engines.mjs index 2b7a391..5e39378 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -283,20 +283,36 @@ export function resolveEngine(token) { // script unpacks to $HOME/.turso and only appends it to your shell profile). // Searching them after PATH keeps a real `turso` on PATH winning, while still // finding the one we just installed. +/** + * The name to print when talking about a `bin` that may be several. + * + * A `bin` is normally one string. ImageMagick is why it can be a list: the + * command is `magick` on ImageMagick 7 and `convert` on 6, both are current on + * supported distros at the same time, and picking either one alone makes a + * successful install report as missing on half of them. The first name is the + * one we prefer and the one worth naming in a message. + */ +export function primaryBin(bin) { + return Array.isArray(bin) ? bin[0] : bin; +} + function executableCandidates(bin, extraDirs = []) { const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""]; - const dirs = path.isAbsolute(bin) || bin.includes(path.sep) - ? [""] - : [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)]; + const names = (Array.isArray(bin) ? bin : [bin]).filter(Boolean); const seen = new Set(); const candidates = []; - for (const dir of dirs) { - for (const ext of exts) { - const candidate = dir ? path.join(dir, bin + ext) : bin + ext; - const key = candidate.toLowerCase(); - if (!seen.has(key)) { - seen.add(key); - candidates.push(candidate); + for (const name of names) { + const dirs = path.isAbsolute(name) || name.includes(path.sep) + ? [""] + : [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)]; + for (const dir of dirs) { + for (const ext of exts) { + const candidate = dir ? path.join(dir, name + ext) : name + ext; + const key = candidate.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + candidates.push(candidate); + } } } } @@ -323,7 +339,9 @@ function nodeShebang(file) { function spawnSpec(bin, args = [], extraDirs = []) { const resolved = resolveExecutable(bin, extraDirs); - if (!resolved) return { cmd: bin, args }; + // Unresolved, so hand the spawn the preferred name and let it produce the + // ENOENT — a list would be spawned as a single nonsense filename. + if (!resolved) return { cmd: primaryBin(bin), args }; if (process.platform === "win32" && path.extname(resolved) === "" && nodeShebang(resolved)) { return { cmd: process.execPath, args: [resolved, ...args] }; } diff --git a/src/pkg-install.mjs b/src/pkg-install.mjs new file mode 100644 index 0000000..056e567 --- /dev/null +++ b/src/pkg-install.mjs @@ -0,0 +1,195 @@ +// Installer for the tools that only ship through a system package manager. +// +// ffmpeg and ImageMagick are the odd ones in TOOLS: they are not a vendor's CLI +// with a `curl … | sh` of its own, and they are not a static binary on a GitHub +// release either. They are distro packages, which is why they are installed the +// way a distro package is installed — and why this is a separate file from +// release-install.mjs rather than another descriptor in it. +// +// Static builds do exist for both. They are third-party redistributions of +// somebody else's codec stack, unsigned, and updated by nobody in particular. +// Downloading one to avoid a sudo prompt would be trading a password for a +// binary we cannot vouch for, on the two tools most likely to be pointed at a +// file from the internet. +// +// Everything that decides *what to run* is a pure function so the per-manager +// argv (which differ in irritating ways) is unit-tested offline; the only +// impure part is the loop at the bottom that runs it. +import { spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { findEscalator } from "./escalate.mjs"; + +/** + * How each manager installs, non-interactively. + * + * Non-interactive is the point: this runs inside `moshcode install` and inside + * `moshcode upgrade tools`, and a manager that stops to ask "Do you want to + * continue? [Y/n]" inside an upgrade sweep parks the whole plan. + * + * apt refreshes first because its index goes stale on its own: a box that has + * not run `apt-get update` in a few months gets a 404 on the archive rather + * than a package, and the error names a URL instead of the actual problem. + */ +export const MANAGERS = { + brew: { + // Never escalated. Homebrew refuses to run as root and says so at length. + root: false, + steps: (pkg) => [["brew", ["install", pkg]]], + }, + "apt-get": { + root: true, + steps: (pkg) => [ + ["apt-get", ["update", "-qq"]], + ["apt-get", ["install", "-y", "--no-install-recommends", pkg]], + ], + }, + dnf: { root: true, steps: (pkg) => [["dnf", ["install", "-y", pkg]]] }, + zypper: { root: true, steps: (pkg) => [["zypper", ["--non-interactive", "install", pkg]]] }, + pacman: { root: true, steps: (pkg) => [["pacman", ["-S", "--needed", "--noconfirm", pkg]]] }, + apk: { root: true, steps: (pkg) => [["apk", ["add", "--no-cache", pkg]]] }, +}; + +/** The order managers are probed in. brew first, and only because of macOS. */ +export const MANAGER_ORDER = ["brew", "apt-get", "dnf", "zypper", "pacman", "apk"]; + +/** + * Package names per tool, per manager, in the order they are worth trying. + * + * Two entries have more than one name and both are facts about somebody else's + * archive rather than hedging: + * + * Fedora ships `ffmpeg-free` in the main repositories and the full `ffmpeg` + * only from RPM Fusion, so a box without that repo enabled has exactly one of + * the two names and `dnf install ffmpeg` fails outright on it. + * + * `imagemagick` is one name for two different programs: on Ubuntu up to + * 24.04 it depends on the 6.x package and puts `convert` on PATH, and from + * 25.04 it depends on the 7.x one and puts `magick` there instead. The + * package name is stable, which is why this table has one entry and the + * tool's `bin` has two. + */ +export const PACKAGES = { + ffmpeg: { + brew: ["ffmpeg"], + "apt-get": ["ffmpeg"], + dnf: ["ffmpeg", "ffmpeg-free"], + zypper: ["ffmpeg"], + pacman: ["ffmpeg"], + apk: ["ffmpeg"], + }, + imagemagick: { + brew: ["imagemagick"], + "apt-get": ["imagemagick"], + dnf: ["ImageMagick"], + zypper: ["ImageMagick"], + pacman: ["imagemagick"], + apk: ["imagemagick"], + }, +}; + +function defaultProbe(tool) { + return spawnSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }).status === 0; +} + +/** Resolve a name to its package table, or throw. Own properties only. */ +export function resolvePackage(tool) { + const key = String(tool ?? "").trim().toLowerCase(); + if (!Object.hasOwn(PACKAGES, key)) { + throw new Error( + `unknown package ${JSON.stringify(tool)} — expected one of ${Object.keys(PACKAGES).join(", ")}`, + ); + } + return [key, PACKAGES[key]]; +} + +/** Which package manager this machine has, or null. */ +export function findManager({ probe = defaultProbe, order = MANAGER_ORDER } = {}) { + for (const name of order) { + if (probe(name)) return name; + } + return null; +} + +/** + * The commands that install one package name with one manager. + * + * Escalation is applied here rather than by the caller because whether a step + * needs it is a property of the manager: brew must not be escalated, the rest + * must be unless we are already root. A `null` escalator on a manager that + * needs one yields the bare command, which fails with the manager's own + * permission message — better advice than anything we would write. + */ +export function installSteps(manager, pkg, { escalator = null, isRoot = false } = {}) { + const spec = MANAGERS[manager]; + if (!spec) throw new Error(`unknown package manager ${JSON.stringify(manager)}`); + const escalate = spec.root && !isRoot && escalator; + return spec.steps(pkg).map(([cmd, args]) => + escalate ? { cmd: escalator, args: [cmd, ...args] } : { cmd, args }, + ); +} + +/** + * Install a tool through whichever package manager is here. + * + * Package names are tried in order and the first that installs wins, because a + * name that is absent from this box's archive is a normal outcome (see the + * Fedora note above) rather than a failure to report. Only when every candidate + * has failed is there something to say. + */ +export function installPackage(tool, { run = spawnSync, probe = defaultProbe, log = console.log } = {}) { + const [key, table] = resolvePackage(tool); + const manager = findManager({ probe }); + if (!manager) { + throw new Error( + `no supported package manager found (${MANAGER_ORDER.join(", ")}) — install ${key} yourself and re-run`, + ); + } + + const candidates = table[manager]; + if (!candidates?.length) { + throw new Error(`${key} has no known package name for ${manager} — install it yourself and re-run`); + } + + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + const escalator = MANAGERS[manager].root && !isRoot ? findEscalator({ probe }) : null; + + const failures = []; + for (const pkg of candidates) { + log(`↓ ${manager} ${pkg}`); + let ok = true; + for (const step of installSteps(manager, pkg, { escalator, isRoot })) { + const result = run(step.cmd, step.args, { stdio: "inherit" }); + if (result?.error || result?.status !== 0) { + failures.push(`${pkg}: ${step.cmd} ${step.args.join(" ")} ${result?.error ? `(${result.error.message})` : `exited ${result?.status}`}`); + ok = false; + break; + } + } + if (ok) { + log(`✓ ${key} installed with ${manager}`); + return { manager, pkg }; + } + } + + throw new Error(`could not install ${key} with ${manager}:\n ${failures.join("\n ")}`); +} + +/** True when this file was executed directly rather than imported. */ +function invokedDirectly() { + try { + return realpathSync(process.argv[1] || "") === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (invokedDirectly()) { + try { + installPackage(process.argv[2]); + } catch (e) { + console.error(`install failed: ${e.message}`); + process.exit(1); + } +} diff --git a/src/release-install.mjs b/src/release-install.mjs index 984a153..e4d3b6c 100644 --- a/src/release-install.mjs +++ b/src/release-install.mjs @@ -29,6 +29,8 @@ import { fileURLToPath } from "node:url"; * - supabase also publishes version-less asset aliases, so `latest/download` * resolves without asking the API for a tag first. * - doctl separates its asset fields with "-" instead of "_". + * - yt-dlp publishes the executable itself rather than an archive, so there + * is nothing to unpack; `bare` is what says so. */ export const RELEASES = { gh: { @@ -54,6 +56,27 @@ export const RELEASES = { asset: ({ version, platform, arch }) => `doctl-${version}-${platform}-${arch}.tar.gz`, binPath: () => "doctl", }, + "yt-dlp": { + repo: "yt-dlp/yt-dlp", + binary: "yt-dlp", + // The asset IS the executable — a PyInstaller bundle, so it needs no + // python on the box, and there is no archive around it to unpack. + bare: true, + // `unversioned` is not a convenience here, it is required: yt-dlp tags + // releases by date with no leading "v" (2025.08.11), so the versioned URL + // this builds otherwise — /download/v2025.08.11/ — is a 404. The + // /releases/latest/download/ alias sidesteps the tag spelling entirely. + unversioned: true, + // macOS gets one universal2 build for both architectures; Linux names arm64 + // "aarch64" while every other vendor here calls it arm64. + asset: ({ platform, arch }) => + platform === "darwin" + ? "yt-dlp_macos" + : arch === "arm64" + ? "yt-dlp_linux_aarch64" + : "yt-dlp_linux", + binPath: () => "yt-dlp", + }, }; // Node's process.arch names differ from the ones release assets use. @@ -151,14 +174,18 @@ export async function installRelease(tool, { fetchImpl = fetch } = {}) { try { const archive = path.join(work, path.posix.basename(new URL(url).pathname)); writeFileSync(archive, Buffer.from(await res.arrayBuffer())); - const unpacked = path.join(work, "unpacked"); - mkdirSync(unpacked); - extract(archive, unpacked); - - const relative = spec.binPath(target); - const from = path.join(unpacked, relative); - if (!existsSync(from)) { - throw new Error(`${spec.binary} was not at ${relative} inside ${path.basename(archive)} — the vendor's archive layout changed`); + + let from = archive; + if (!spec.bare) { + const unpacked = path.join(work, "unpacked"); + mkdirSync(unpacked); + extract(archive, unpacked); + + const relative = spec.binPath(target); + from = path.join(unpacked, relative); + if (!existsSync(from)) { + throw new Error(`${spec.binary} was not at ${relative} inside ${path.basename(archive)} — the vendor's archive layout changed`); + } } const dir = installDir(); diff --git a/src/tools.mjs b/src/tools.mjs index f9cfe8c..58d77ef 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -18,6 +18,12 @@ import { isInstalled, openPassthrough } from "./engines.mjs"; const RELEASE_INSTALLER = path.join(path.dirname(fileURLToPath(import.meta.url)), "release-install.mjs"); const releaseInstall = (tool) => ({ cmd: process.execPath, args: [RELEASE_INSTALLER, tool] }); +// ffmpeg and ImageMagick ship as distro packages and nothing else — no vendor +// installer, no release binary we would trust. See src/pkg-install.mjs for why +// the static rebuilds floating around are not an option here. +const PACKAGE_INSTALLER = path.join(path.dirname(fileURLToPath(import.meta.url)), "pkg-install.mjs"); +const packageInstall = (tool) => ({ cmd: process.execPath, args: [PACKAGE_INSTALLER, tool] }); + export const TOOLS = { ugig: { desc: "UGig — freelance marketplace CLI for humans and agents", @@ -354,6 +360,55 @@ export const TOOLS = { // there is deliberately no `upgrade` key. install: { cmd: "npm", args: ["install", "-g", "@elevenlabs/cli"] }, }, + "yt-dlp": { + desc: "yt-dlp — download video and audio from a URL (a thousand sites, not just YouTube)", + bin: "yt-dlp", + // The three tools below are not workflow CLIs like everything above: they + // are the media toolchain `cli-tools` builds on. `dl` is a front for + // yt-dlp, `vid` for ffmpeg and `img` for ImageMagick, and all three used to + // tell you to go and install a system package by hand. Now the same + // registry that installs cli-tools can install what it runs on. + // + // A PyInstaller bundle from the project's own releases, so it needs no + // python and no package manager, and it lands in ~/.local/bin like + // gh/supabase/doctl. Distro packages of yt-dlp are the one thing worth + // avoiding here: extractors break whenever a site changes, upstream ships a + // fix within days, and a distro package is frozen for the life of a release. + install: releaseInstall("yt-dlp"), + // Which is also why the upgrade is yt-dlp's own `-U` rather than a + // re-download: it is the update path the project documents, it checks + // before it fetches, and it is the one an operator will reach for anyway. + // On a yt-dlp that came from a package manager instead, `-U` declines and + // says so, which is the correct answer rather than a failure. + upgrade: { cmd: "yt-dlp", args: ["-U"] }, + // Same gap turso, gradient and kimi have: nothing appends to PATH. + binDirs: [path.join(homedir(), ".local", "bin")], + }, + ffmpeg: { + desc: "ffmpeg — convert, cut, scale and inspect audio and video", + bin: "ffmpeg", + // Through the distro package manager, which means root everywhere but + // macOS, where Homebrew refuses to run as root at all. Same shape as + // tailscale, and for the same reason: get the password prompt out of the + // way before a sweep starts rather than partway through one. + needsRoot: { except: ["darwin"] }, + install: packageInstall("ffmpeg"), + // No upgrade key: `apt-get install` / `brew install` on a package that is + // already there upgrades it, so re-running the install IS the upgrade — + // the same reasoning as mcpjam and railway, and toolUpgradeSpec falls back + // to install on its own. + }, + imagemagick: { + desc: "ImageMagick — resize, convert and composite images from the command line", + // Two names, deliberately. The command is `magick` on ImageMagick 7 and + // `convert` on 6, and both are current: Ubuntu 24.04 and earlier ship 6, + // 25.04 and later ship 7, and the package is called `imagemagick` on both. + // A single name would report a perfectly good install as missing on + // whichever half of the fleet has the other one. + bin: ["magick", "convert"], + needsRoot: { except: ["darwin"] }, + install: packageInstall("imagemagick"), + }, }; /** Resolve a name to `[key, tool]`, or null. */ @@ -377,7 +432,7 @@ export function toolStatus() { export function toolList() { return Object.entries(TOOLS) - .map(([key, tool]) => ` ${key.padEnd(10)} ${tool.desc}`) + .map(([key, tool]) => ` ${key.padEnd(11)} ${tool.desc}`) .join("\n"); } diff --git a/test/pkg-install.test.mjs b/test/pkg-install.test.mjs new file mode 100644 index 0000000..888a8c9 --- /dev/null +++ b/test/pkg-install.test.mjs @@ -0,0 +1,116 @@ +// ffmpeg and ImageMagick are the two tools moshcode installs through the box's +// own package manager rather than a vendor script or a release binary, and the +// per-manager argv differ in ways that are easy to get almost right: a manager +// that stops to ask "continue? [Y/n]" parks a whole `moshcode upgrade` sweep, +// and escalating Homebrew makes it refuse outright. These pin both. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MANAGERS, MANAGER_ORDER, PACKAGES, findManager, installSteps, installPackage, resolvePackage, +} from "../src/pkg-install.mjs"; + +test("every manager installs without asking a question", () => { + // Nothing here runs with a human watching: `moshcode upgrade tools` walks a + // plan, and a confirmation prompt parks it behind a keystroke nobody types. + const assumeYes = { + brew: null, // brew install is non-interactive already + "apt-get": "-y", + dnf: "-y", + zypper: "--non-interactive", + pacman: "--noconfirm", + apk: "--no-cache", + }; + for (const [name, flag] of Object.entries(assumeYes)) { + if (!flag) continue; + const flat = MANAGERS[name].steps("pkg").flatMap(([, args]) => args); + assert.ok(flat.includes(flag), `${name} is missing ${flag}`); + } +}); + +test("apt refreshes its index before installing from it", () => { + // A box that has not run `apt-get update` in months 404s on the archive, and + // the error names a URL rather than the actual problem. + const [first] = MANAGERS["apt-get"].steps("ffmpeg"); + assert.deepEqual(first, ["apt-get", ["update", "-qq"]]); +}); + +test("installSteps escalates the managers that need root, and only those", () => { + assert.deepEqual(installSteps("apt-get", "ffmpeg", { escalator: "sudo" }), [ + { cmd: "sudo", args: ["apt-get", "update", "-qq"] }, + { cmd: "sudo", args: ["apt-get", "install", "-y", "--no-install-recommends", "ffmpeg"] }, + ]); + // Homebrew refuses to run as root and says so at length, so escalating it + // turns a working install into a lecture. + assert.deepEqual(installSteps("brew", "ffmpeg", { escalator: "sudo" }), [ + { cmd: "brew", args: ["install", "ffmpeg"] }, + ]); +}); + +test("installSteps does not escalate when it is already root", () => { + assert.deepEqual(installSteps("dnf", "ffmpeg", { escalator: "sudo", isRoot: true }), [ + { cmd: "dnf", args: ["install", "-y", "ffmpeg"] }, + ]); +}); + +test("installSteps runs bare when there is no escalator, so the manager explains itself", () => { + // A container with no sudo is a normal place to end up. The manager's own + // permission message is better advice than anything we would write. + assert.deepEqual(installSteps("apk", "ffmpeg", { escalator: null }), [ + { cmd: "apk", args: ["add", "--no-cache", "ffmpeg"] }, + ]); +}); + +test("brew is probed before the linux managers", () => { + // A mac with Linuxbrew-adjacent tooling should still land on brew. + assert.equal(MANAGER_ORDER[0], "brew"); + assert.equal(findManager({ probe: (m) => m === "brew" || m === "apt-get" }), "brew"); + assert.equal(findManager({ probe: () => false }), null); +}); + +test("every tool has a package name for every manager", () => { + for (const [tool, table] of Object.entries(PACKAGES)) { + for (const manager of MANAGER_ORDER) { + assert.ok(table[manager]?.length, `${tool} has no ${manager} package`); + } + } +}); + +test("ffmpeg carries Fedora's second name", () => { + // Fedora ships `ffmpeg-free` in its own repositories and the full `ffmpeg` + // only from RPM Fusion, so a box without that repo has exactly one of them + // and `dnf install ffmpeg` fails outright on it. + assert.deepEqual(PACKAGES.ffmpeg.dnf, ["ffmpeg", "ffmpeg-free"]); +}); + +test("resolvePackage reads own properties only", () => { + assert.throws(() => resolvePackage("constructor"), /unknown package/); + assert.throws(() => resolvePackage("__proto__"), /unknown package/); + assert.equal(resolvePackage("FFmpeg")[0], "ffmpeg"); +}); + +test("installPackage tries the next package name when one is not in the archive", () => { + const seen = []; + const run = (cmd, args) => { + seen.push([cmd, ...args].join(" ")); + // Refuse the first candidate the way dnf refuses a name it cannot resolve. + return { status: args.includes("ffmpeg") && !args.includes("ffmpeg-free") ? 1 : 0 }; + }; + const result = installPackage("ffmpeg", { run, probe: (m) => m === "dnf", log: () => {} }); + assert.deepEqual(result, { manager: "dnf", pkg: "ffmpeg-free" }); + assert.ok(seen.some((c) => c.includes("ffmpeg-free"))); +}); + +test("installPackage reports every failure rather than the last one", () => { + assert.throws( + () => installPackage("ffmpeg", { run: () => ({ status: 1 }), probe: (m) => m === "dnf", log: () => {} }), + (e) => /could not install ffmpeg with dnf/.test(e.message) && /ffmpeg-free/.test(e.message), + ); +}); + +test("installPackage says what to do when the box has no package manager", () => { + assert.throws( + () => installPackage("ffmpeg", { run: () => ({ status: 0 }), probe: () => false, log: () => {} }), + /no supported package manager found/, + ); +}); diff --git a/test/release-install.test.mjs b/test/release-install.test.mjs index 8d81014..f45cee3 100644 --- a/test/release-install.test.mjs +++ b/test/release-install.test.mjs @@ -94,3 +94,37 @@ test("installDir honours MOSHCODE_BIN, matching install.sh", () => { else process.env.MOSHCODE_BIN = previous; } }); + +test("yt-dlp downloads the executable itself, with no archive around it", () => { + const spec = RELEASES["yt-dlp"]; + // `bare` is what tells installRelease to skip the unpack; without it the + // installer would hand a PyInstaller binary to tar. + assert.equal(spec.bare, true); + // Tagged by date with no leading "v" (2025.08.11), so the versioned URL this + // builds otherwise — /download/v2025.08.11/ — is a 404 on every release. + assert.equal(spec.unversioned, true); + assert.equal( + assetUrl(spec, { platform: "linux", arch: "amd64" }), + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux", + ); + // Linux names arm64 "aarch64" here; every other vendor in this file does not. + assert.equal( + assetUrl(spec, { platform: "linux", arch: "arm64" }), + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_aarch64", + ); + // One universal2 build serves both Mac architectures. + assert.equal( + assetUrl(spec, { platform: "darwin", arch: "arm64" }), + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos", + ); + assert.equal( + assetUrl(spec, { platform: "darwin", arch: "amd64" }), + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos", + ); +}); + +test("only yt-dlp is bare — the rest still have an archive to unpack", () => { + for (const [key, spec] of Object.entries(RELEASES)) { + assert.equal(Boolean(spec.bare), key === "yt-dlp", `${key} has the wrong bare flag`); + } +}); diff --git a/test/tools.test.mjs b/test/tools.test.mjs index c2aa417..8f70728 100644 --- a/test/tools.test.mjs +++ b/test/tools.test.mjs @@ -16,7 +16,8 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import test from "node:test"; -import { isInstalled, resolveEngine } from "../src/engines.mjs"; +import { isInstalled, primaryBin, resolveEngine } from "../src/engines.mjs"; +import { needsRootHere } from "../src/escalate.mjs"; import { TOOLS, resolveTool, retry, toolList, toolUpgradeSpec } from "../src/tools.mjs"; const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); @@ -593,3 +594,60 @@ test("retry retries until a later attempt succeeds", async () => { assert.equal(result, "ok"); assert.equal(calls, 2); }); + +test("the media toolchain installs through the right mechanism for each tool", () => { + // yt-dlp ships a static binary on its own releases; ffmpeg and ImageMagick + // only exist as distro packages. Getting these crossed means an install that + // 404s or one that asks for a password it never needed. + assert.equal(TOOLS["yt-dlp"].install.args.at(-1), "yt-dlp"); + assert.ok(TOOLS["yt-dlp"].install.args.some((a) => a.endsWith("release-install.mjs"))); + for (const key of ["ffmpeg", "imagemagick"]) { + assert.ok(TOOLS[key].install.args.some((a) => a.endsWith("pkg-install.mjs")), key); + } +}); + +test("the package-manager tools ask for root everywhere but macOS", () => { + // Homebrew refuses to run as root, so a mac must not be prompted for a + // password by a step that never escalates. + for (const key of ["ffmpeg", "imagemagick"]) { + assert.equal(needsRootHere(TOOLS[key], "linux"), true, key); + assert.equal(needsRootHere(TOOLS[key], "darwin"), false, key); + } + // yt-dlp lands in ~/.local/bin, so it never escalates at all. + assert.equal(needsRootHere(TOOLS["yt-dlp"], "linux"), false); +}); + +test("yt-dlp upgrades with its own -U rather than a re-download", () => { + // Extractors break whenever a site changes and upstream ships a fix within + // days, so the update path has to be the one that actually gets used. + assert.deepEqual(toolUpgradeSpec(TOOLS["yt-dlp"]), { cmd: "yt-dlp", args: ["-U"] }); + // ffmpeg has no updater of its own; re-running the package install upgrades + // it, which is what toolUpgradeSpec falls back to. + assert.equal(toolUpgradeSpec(TOOLS.ffmpeg), TOOLS.ffmpeg.install); +}); + +test("imagemagick is found under either of the names it ships as", () => { + // `magick` on ImageMagick 7, `convert` on 6, and both are current: Ubuntu + // 24.04 and earlier ship 6, 25.04 and later ship 7, under the same package + // name. A single `bin` reports a good install as missing on half the fleet. + assert.deepEqual(TOOLS.imagemagick.bin, ["magick", "convert"]); + + const dir = tempDir("moshcode-magick-"); + const PATH = `${dir}${path.delimiter}${process.env.PATH}`; + const withPath = (fn) => { + const before = process.env.PATH; + process.env.PATH = PATH; + try { return fn(); } finally { process.env.PATH = before; } + }; + + assert.equal(withPath(() => isInstalled(TOOLS.imagemagick.bin)), false); + writeExecutable(dir, "convert", "process.exit(0)"); + assert.equal(withPath(() => isInstalled(TOOLS.imagemagick.bin)), true, "ImageMagick 6"); + writeExecutable(dir, "magick", "process.exit(0)"); + assert.equal(withPath(() => isInstalled(TOOLS.imagemagick.bin)), true, "ImageMagick 7"); +}); + +test("primaryBin names one command, so a list never reaches a message or a spawn", () => { + assert.equal(primaryBin(["magick", "convert"]), "magick"); + assert.equal(primaryBin("ffmpeg"), "ffmpeg"); +}); diff --git a/test/upgrade-install-missing.test.mjs b/test/upgrade-install-missing.test.mjs index 3ae1e5c..efcd030 100644 --- a/test/upgrade-install-missing.test.mjs +++ b/test/upgrade-install-missing.test.mjs @@ -108,10 +108,19 @@ test("a missing target's plan is a command that could actually install it", () = // Guards the fix's intent rather than its shape: an installer fetches // something, so the planned command line mentions a fetcher or a package // manager. A bare `doppler update` satisfies neither. + // + // Our own two installers count as fetchers, and have to: release-install.mjs + // downloads a release asset with node's fetch and pkg-install.mjs shells into + // whichever package manager the box has, so neither spells "curl" on the + // command line while both do exactly what this test is asking about. for (const [key] of withNativeUpdater) { const { spec } = specOf(key); const line = [spec.cmd, ...(spec.args ?? [])].join(" "); - assert.match(line, /curl|wget|npm|pip|brew/, `${key} plan is not an install command: ${line}`); + assert.match( + line, + /curl|wget|npm|pip|brew|release-install\.mjs|pkg-install\.mjs/, + `${key} plan is not an install command: ${line}`, + ); } });