Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ENGINES,
engineStatus,
openSession,
primaryBin,
resolveEngine,
resolveExecutable,
runCmd,
Expand Down Expand Up @@ -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}`);
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 29 additions & 11 deletions src/engines.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand All @@ -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] };
}
Expand Down
195 changes: 195 additions & 0 deletions src/pkg-install.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading