Skip to content

Commit 5a16bc3

Browse files
ralyodioclaude
andauthored
feat(tools): install yt-dlp, ffmpeg and ImageMagick (#448)
The three odd ones out in TOOLS: not workflow CLIs, but the media toolchain the rest of the roster is built on. cli-tools fronts all of them — `dl` for yt-dlp, `vid` for ffmpeg, `img` for ImageMagick — and every one of those 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. Each arrives the way it actually ships, which is three different ways: yt-dlp comes from its own releases as a self-contained PyInstaller binary, so it needs no python and no package manager and lands in ~/.local/bin like gh/supabase/doctl. Deliberately not the distro package: extractors break whenever a site changes its markup, upstream ships a fix within days, and a distro package is frozen for the life of a release. Its upgrade is `yt-dlp -U`, the project's own updater. release-install.mjs grew a `bare` flag for it — the asset is the executable, with no archive to unpack — and it must be `unversioned`, because yt-dlp tags by date with no leading "v" and the versioned URL would be /download/v2026.08.19/, a 404 on every release. ffmpeg and ImageMagick exist only as distro packages. Static rebuilds are floating around, but they 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 — trading a sudo prompt for that is not a trade worth making. So src/pkg-install.mjs dispatches to apt/dnf/zypper/pacman/apk, or Homebrew on macOS. Everything that decides what to run is pure and unit-tested; only the loop that runs it is not. Three things in there are facts about somebody else's archive rather than preference. apt refreshes its index first, because a box that has not updated in months 404s on the archive and the error names a URL instead of the problem. Every manager is invoked non-interactively, because a "continue? [Y/n]" inside `moshcode upgrade tools` parks the whole sweep. And Fedora ships `ffmpeg-free` in its own repositories with the full `ffmpeg` only in RPM Fusion, so package names are a list tried in order rather than one string. `bin` can now be a list of names. ImageMagick is why: the command is `magick` on version 7 and `convert` on 6, both current across supported distros under the same package name, so a single name reports a good install as missing on half the fleet. resolveExecutable tries each; primaryBin() is what reaches a spawn fallback or a message, so a list never gets printed or executed as one. Two existing tests moved with it, both because their assumption expired rather than because they were wrong. `tools --json` promises `binary` as a string, so it emits primaryBin. And the install-plan test asserted a plan mentions curl/wget/npm/pip/brew — yt-dlp is the first entry with both a native updater and one of our own installers, which fetch with node and with the system package manager and so spell neither. Verified end to end: all three release assets resolve 200, and installing yt-dlp into a temp MOSHCODE_BIN produces a working binary (2026.08.19). Claude-Session: https://claude.ai/code/session_01LkEHoAhsoDhqVeJ1MJH9yD Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eb213be commit 5a16bc3

10 files changed

Lines changed: 580 additions & 30 deletions

‎README.md‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -762,15 +762,50 @@ newer, and re-running `moshcode install alchemy` is its upgrade path.
762762
Where CoinPay is the payments product MoshCode ships alongside, Alchemy is the
763763
read side of the same world — the chain itself rather than one wallet's ledger.
764764

765+
### yt-dlp, ffmpeg, ImageMagick — the media toolchain
766+
767+
```sh
768+
moshcode install yt-dlp # static binary → ~/.local/bin
769+
moshcode install ffmpeg # your distro's package manager (needs sudo)
770+
moshcode install imagemagick # likewise
771+
772+
moshcode yt-dlp https://… # or `dl https://…` from cli-tools
773+
moshcode ffmpeg -i in.mkv out.mp4
774+
```
775+
776+
The odd three out: not workflow CLIs, but the media toolchain the rest of the
777+
roster is built on. `cli-tools` fronts all three — `dl` for yt-dlp, `vid` for
778+
ffmpeg, `img` for ImageMagick — and every one of them used to answer a missing
779+
binary by telling you to go and install a system package by hand. Now the
780+
registry that installs `cli-tools` installs what it runs on.
781+
782+
**yt-dlp** comes from its own releases as a self-contained binary, so it needs
783+
no python and no package manager. That is deliberate rather than convenient:
784+
extractors break whenever a site changes its markup, upstream ships a fix within
785+
days, and a distro package of yt-dlp is frozen for the life of a release. Its
786+
upgrade is `yt-dlp -U`, the project's own updater.
787+
788+
**ffmpeg** and **ImageMagick** exist only as distro packages — no vendor script,
789+
and the static rebuilds floating around are unsigned third-party redistributions
790+
of somebody else's codec stack, on the two tools most likely to be pointed at a
791+
file from the internet. So they go through `apt`/`dnf`/`zypper`/`pacman`/`apk`,
792+
or Homebrew on macOS, and ask for sudo everywhere but a Mac (see below).
793+
Re-running the install upgrades them.
794+
795+
ImageMagick answers to two names: `magick` on version 7, `convert` on 6, both
796+
current across supported distros under the same package name. MoshCode looks for
797+
either, so a good install is never reported missing.
798+
765799
`gh`, `supabase`, and `doctl` publish no cross-platform install script, so
766800
MoshCode resolves the latest GitHub release and drops the binary in
767801
`$MOSHCODE_BIN` (default `~/.local/bin`) — no sudo, no package manager. Set
768802
`MOSHCODE_BIN` to install elsewhere.
769803

770-
`tailscale` and `spinifex` are the exceptions: both install system services
771-
rather than a user-local binary, so their official installers go through the
772-
distro's package manager and will ask for sudo (tailscale on macOS delegates to
773-
the App Store instead; Spinifex has no macOS build at all).
804+
`tailscale`, `spinifex`, `ffmpeg` and `imagemagick` are the exceptions: none of
805+
them is a user-local binary, so they go through the distro's package manager and
806+
will ask for sudo (tailscale on macOS delegates to the App Store, and `ffmpeg`
807+
and `imagemagick` to Homebrew, which refuses to run as root — so neither is
808+
prompted for a password on a Mac; Spinifex has no macOS build at all).
774809

775810
MoshCode asks for that password **before** starting the work rather than letting
776811
the installer stop for it partway through — which matters most in `moshcode

‎bin/moshcode.mjs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ENGINES,
1111
engineStatus,
1212
openSession,
13+
primaryBin,
1314
resolveEngine,
1415
resolveExecutable,
1516
runCmd,
@@ -109,13 +110,15 @@ function printStatus(entries, json = false) {
109110
console.log(JSON.stringify(entries.map(({ key, desc, bin, installed }) => ({
110111
name: key,
111112
description: desc,
112-
binary: bin,
113+
// One name, even for an entry that answers to several: `binary` is a
114+
// documented string in this JSON and something is parsing it.
115+
binary: primaryBin(bin),
113116
installed,
114117
})), null, 2));
115118
return;
116119
}
117120
for (const entry of entries) {
118-
console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(10)} ${entry.desc}`);
121+
console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(11)} ${entry.desc}`);
119122
}
120123
}
121124

@@ -375,7 +378,7 @@ async function main() {
375378
const r = await openTool(tool, translated.args);
376379
if (!r.ok) {
377380
console.error(r.error?.code === "ENOENT"
378-
? `alpaca isn't installed (\`${tool.bin}\`). run: moshcode install alpaca`
381+
? `alpaca isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install alpaca`
379382
: `launch failed: ${r.error?.message || r.error}`);
380383
process.exitCode = 1;
381384
return;
@@ -806,7 +809,7 @@ async function main() {
806809
const r = await openTool(tool, rest);
807810
if (!r.ok) {
808811
console.error(r.error?.code === "ENOENT"
809-
? `${key} isn't installed (\`${tool.bin}\`). run: moshcode install ${key}`
812+
? `${key} isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install ${key}`
810813
: `launch failed: ${r.error?.message || r.error}`);
811814
process.exitCode = 1;
812815
return;

‎src/engines.mjs‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -283,20 +283,36 @@ export function resolveEngine(token) {
283283
// script unpacks to $HOME/.turso and only appends it to your shell profile).
284284
// Searching them after PATH keeps a real `turso` on PATH winning, while still
285285
// finding the one we just installed.
286+
/**
287+
* The name to print when talking about a `bin` that may be several.
288+
*
289+
* A `bin` is normally one string. ImageMagick is why it can be a list: the
290+
* command is `magick` on ImageMagick 7 and `convert` on 6, both are current on
291+
* supported distros at the same time, and picking either one alone makes a
292+
* successful install report as missing on half of them. The first name is the
293+
* one we prefer and the one worth naming in a message.
294+
*/
295+
export function primaryBin(bin) {
296+
return Array.isArray(bin) ? bin[0] : bin;
297+
}
298+
286299
function executableCandidates(bin, extraDirs = []) {
287300
const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
288-
const dirs = path.isAbsolute(bin) || bin.includes(path.sep)
289-
? [""]
290-
: [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)];
301+
const names = (Array.isArray(bin) ? bin : [bin]).filter(Boolean);
291302
const seen = new Set();
292303
const candidates = [];
293-
for (const dir of dirs) {
294-
for (const ext of exts) {
295-
const candidate = dir ? path.join(dir, bin + ext) : bin + ext;
296-
const key = candidate.toLowerCase();
297-
if (!seen.has(key)) {
298-
seen.add(key);
299-
candidates.push(candidate);
304+
for (const name of names) {
305+
const dirs = path.isAbsolute(name) || name.includes(path.sep)
306+
? [""]
307+
: [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)];
308+
for (const dir of dirs) {
309+
for (const ext of exts) {
310+
const candidate = dir ? path.join(dir, name + ext) : name + ext;
311+
const key = candidate.toLowerCase();
312+
if (!seen.has(key)) {
313+
seen.add(key);
314+
candidates.push(candidate);
315+
}
300316
}
301317
}
302318
}
@@ -323,7 +339,9 @@ function nodeShebang(file) {
323339

324340
function spawnSpec(bin, args = [], extraDirs = []) {
325341
const resolved = resolveExecutable(bin, extraDirs);
326-
if (!resolved) return { cmd: bin, args };
342+
// Unresolved, so hand the spawn the preferred name and let it produce the
343+
// ENOENT — a list would be spawned as a single nonsense filename.
344+
if (!resolved) return { cmd: primaryBin(bin), args };
327345
if (process.platform === "win32" && path.extname(resolved) === "" && nodeShebang(resolved)) {
328346
return { cmd: process.execPath, args: [resolved, ...args] };
329347
}

‎src/pkg-install.mjs‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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

Comments
 (0)