Files
Mike Schwarz f86a80285a fix(install): say the CLI is installed and setup is not complete when rig setup isn't ready (#1547)
When `rig setup` (step 4 of the one-line installer) finishes without being ready, the installer now ends with `INSTALLED, SETUP NOT COMPLETE (exit N).` instead of `FAILED [4/4] … (exit 1)`, which read as "OpenRig didn't install" though the CLI was installed and the compatibility check ran. The summary gives the installed CLI path, the Node/SQLite check result (`passed`, or `SKIPPED (not checked; see above)`), a pointer to setup's own "Some steps need attention" list, with a Fix where setup gives one, or to the error that stopped it, and "Fix those, then run rig setup again; the CLI does not need reinstalling."

- Setup's exit status passes through exactly, and setup's own output stays above the summary.
- Steps 1-3 keep their `FAILED [n/4]` line, and a ready setup keeps today's closing line.
- It names no cause that setup's result doesn't establish: a provider check fails for a missing sign-in but also for a probe that timed out or errored, and the installer sees only setup's exit status, so the summary doesn't say "sign in".
- A skipped compatibility check is never reported as passed. Setup's readiness and exit behaviour, provider selection and every other exit status are unchanged, with no extra probe.

The README and getting-started are unchanged on purpose: they show the pinned release's installer, which still ends with `FAILED [4/4]`. They move together with the pin, at the first release tag that contains this script.

Tests: the wrapper tests cover setup exit 1, 40 and a piped run's 43, unchanged early failures, a skipped check and the ready ending; five setup-boundary cases cover sign-in only, a mixed failure, ready, an unselected provider and an uncertain probe.
2026-10-19 13:16:39 -07:00

214 lines
11 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
import { spawn, spawnSync } from "node:child_process";
const script = path.join(import.meta.dirname, "install.sh");
const source = fs.readFileSync(script, "utf8");
const nodeAdvice = [
"Node.js 22 or 24 with npm",
"Distribution packages can be older; check node --version.",
"official Node installer",
"Linux route (NodeSource or nvm)",
"https://github.com/mvschwarz/openrig/blob/main/docs/reference/getting-started.md",
];
function fixture(t, overrides = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-install-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const bin = path.join(dir, "tools");
const prefix = path.join(dir, "npm prefix with spaces");
const modules = path.join(dir, "global modules with spaces");
const checker = path.join(modules, "@openrig", "cli", "scripts", "check-abi.mjs");
fs.mkdirSync(bin);
fs.mkdirSync(path.join(prefix, "bin"), { recursive: true });
fs.mkdirSync(path.dirname(checker), { recursive: true });
fs.writeFileSync(checker, `import fs from "node:fs";
fs.appendFileSync(process.env.TEST_LOG, "abi:check\\n");
console.log("ABI native output");
console.error("ABI native detail");
process.exitCode = Number(process.env.ABI_EXIT || 0);
`);
const write = (file, body) => fs.writeFileSync(file, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
write(path.join(bin, "node"), 'printf "node:%s\\n" "$*" >>"$TEST_LOG"; if [ "$1" = --version ]; then printf "v22.22.1\\n"; else exec "$TEST_NODE" "$@"; fi');
write(path.join(bin, "npm"), `
printf 'npm:%s\n' "$*" >>"$TEST_LOG"
case "$*" in
--version) printf '%s\n' "$TEST_NPM_VERSION" ;;
'install -g @openrig/cli')
if [ "$SKIP_POSTINSTALL" = 1 ]; then printf 'npm warn install-scripts postinstall skipped\n' >&2; fi
printf 'npm native output\n'; printf 'npm native error\n' >&2; exit "\${INSTALL_EXIT:-0}" ;;
'prefix -g') printf '%s\n' "$TEST_PREFIX"; exit "\${PREFIX_EXIT:-0}" ;;
'root -g') printf '%s\n' "$TEST_MODULES"; exit "\${ROOT_EXIT:-0}" ;;
*) exit 90 ;;
esac`);
write(path.join(bin, "rig"), 'printf "STALE\\n" >>"$TEST_LOG"; exit 91');
write(path.join(prefix, "bin", "rig"), `
printf 'installed:%s\n' "$*" >>"$TEST_LOG"
# Any available stdin would be an implicit answer or unconsumed script bytes.
if read -r answer; then printf 'unexpected stdin\n' >&2; exit 92; fi
case "$*" in
'setup --dry-run') printf 'setup preview\n'; exit "\${PREVIEW_EXIT:-0}" ;;
setup) printf 'setup native output\n'; printf 'setup native error\n' >&2
printf 'Next steps: choose providers, then rig up\n'; exit "\${SETUP_EXIT:-0}" ;;
*) exit 93 ;;
esac`);
const env = { PATH: bin, HOME: dir, TEST_PREFIX: prefix, TEST_MODULES: modules, TEST_NODE: process.execPath, TEST_NPM_VERSION: "10.9.4", TEST_LOG: path.join(dir, "calls"), ...overrides };
return { dir, bin, prefix, checker, env,
calls: () => fs.existsSync(env.TEST_LOG) ? fs.readFileSync(env.TEST_LOG, "utf8").trim().split("\n") : [],
run: (...args) => spawnSync("/bin/sh", [script, ...args], { env, encoding: "utf8", timeout: 10000 }),
};
}
test("dry run needs no tools and only prints the complete plan", t => {
const syntax = spawnSync("/bin/sh", ["-n", script], { encoding: "utf8" });
assert.equal(syntax.status, 0, syntax.stderr);
const f = fixture(t);
f.env.PATH = path.join(f.dir, "absent");
const r = f.run("--dry-run");
assert.equal(r.status, 0, r.stderr);
for (const text of ["[1/4]", "npm install -g @openrig/cli", "npm root -g", "scripts/check-abi.mjs", "even if npm skipped postinstall", "rig setup --dry-run", "[4/4] rig setup", "both Claude Code and Codex", "rig setup installs herdr by default", "unavailable herdr install is a warning", "Existing cmux settings stay unchanged", "rig setup --ghostty attempts it after acceptance", "launch a team or open a kernel conversation", "Dry run:", ...nodeAdvice]) assert.ok(r.stdout.includes(text), text);
assert.deepEqual(f.calls(), []);
});
for (const tool of ["node", "npm"]) test(`missing ${tool} reports the prerequisite step`, t => {
const f = fixture(t);
fs.unlinkSync(path.join(f.bin, tool));
const r = f.run();
assert.equal(r.status, 127);
assert.match(r.stderr, new RegExp(`Missing prerequisite: ${tool}`));
for (const text of nodeAdvice) assert.ok(r.stderr.includes(text), text);
assert.match(r.stderr, /FAILED \[1\/4\].*exit 127/);
assert.deepEqual(f.calls(), []);
});
for (const [variable, code, step, command] of [
["INSTALL_EXIT", 37, 2, "npm install -g @openrig/cli"],
["PREFIX_EXIT", 38, 2, "npm prefix -g"],
["ROOT_EXIT", 45, 2, "npm root -g"],
["ABI_EXIT", 44, 2, "check-abi.mjs"],
["PREVIEW_EXIT", 39, 3, "setup --dry-run"],
]) test(`${command} failure preserves status and native output`, t => {
const f = fixture(t, { [variable]: String(code) });
const r = f.run();
assert.equal(r.status, code, r.stderr);
assert.ok(r.stderr.includes(`FAILED [${step}/4]`), r.stderr);
assert.ok(r.stderr.includes(command), r.stderr);
assert.ok(r.stderr.includes(`exit ${code}`), r.stderr);
assert.match(r.stdout, /npm native output/);
assert.match(r.stderr, /npm native error/);
assert.ok(!r.stdout.includes("Follow the next steps"));
if (step < 4) assert.ok(!f.calls().includes("installed:setup"));
if (variable === "ABI_EXIT") {
assert.match(r.stdout, /ABI native output/);
assert.match(r.stderr, /ABI native detail/);
assert.ok(!r.stdout.includes("Node/SQLite compatibility check passed."));
assert.ok(!f.calls().includes("installed:setup --dry-run"));
}
});
test("installed rig wins over stale PATH and a prefix containing spaces works", t => {
const f = fixture(t);
const r = f.run();
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(f.calls(), ["node:--version", "npm:--version", "npm:install -g @openrig/cli", "npm:prefix -g", "npm:root -g", `node:${f.checker}`, "abi:check", "installed:setup --dry-run", "installed:setup"]);
assert.match(r.stdout, /choose providers, then rig up/);
assert.match(r.stderr, /setup native error/);
});
test("skipped npm postinstall still checks the installed package before setup", t => {
const f = fixture(t, { SKIP_POSTINSTALL: "1", TEST_NPM_VERSION: "11.19.0" });
const r = f.run();
assert.equal(r.status, 0, r.stderr);
assert.match(r.stderr, /npm warn install-scripts postinstall skipped/);
assert.match(r.stdout, /Node\/SQLite compatibility check passed\./);
const calls = f.calls();
assert.equal(calls.filter(x => x === "abi:check").length, 1);
assert.ok(calls.indexOf("abi:check") < calls.indexOf("installed:setup --dry-run"));
});
test("missing installed checker is explicitly skipped without claiming success", t => {
const f = fixture(t);
fs.unlinkSync(f.checker);
const r = f.run();
assert.equal(r.status, 0, r.stderr);
assert.match(r.stderr, /Node\/SQLite compatibility check SKIPPED: installed checker not found/);
assert.ok(!r.stdout.includes("Node/SQLite compatibility check passed."));
assert.ok(!f.calls().includes("abi:check"));
assert.ok(f.calls().includes("installed:setup"));
});
test("missing installed executable never falls back to a stale rig", t => {
const f = fixture(t);
fs.unlinkSync(path.join(f.prefix, "bin", "rig"));
const r = f.run();
assert.equal(r.status, 127);
assert.match(r.stderr, /FAILED \[2\/4\] locate installed rig/);
assert.ok(!f.calls().includes("STALE"));
});
test("the actual HTTP download piped to sh preserves setup failure", async t => {
const f = fixture(t, { SETUP_EXIT: "43" });
const curl = spawnSync("/bin/sh", ["-c", "command -v curl"], { encoding: "utf8" });
assert.equal(curl.status, 0, "curl is required for the pipe control");
fs.symlinkSync(curl.stdout.trim(), path.join(f.bin, "curl"));
const server = http.createServer((_req, res) => res.end(source));
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise(resolve => server.close(resolve)));
const url = `http://127.0.0.1:${server.address().port}/install.sh`;
const child = spawn("/bin/sh", ["-c", 'curl -fsS --max-time 5 "$1" | /bin/sh', "pipe-test", url], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "", stderr = "";
child.stdout.on("data", b => { stdout += b; });
child.stderr.on("data", b => { stderr += b; });
const timer = setTimeout(() => child.kill("SIGKILL"), 10000);
t.after(() => clearTimeout(timer));
const status = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", resolve); });
assert.equal(status, 43, stderr);
assert.match(stdout, /setup native output/);
assert.match(stderr, /setup native error/);
assert.match(stderr, /INSTALLED, SETUP NOT COMPLETE \(exit 43\)/);
assert.ok(!stderr.includes("FAILED [4/4]"), stderr);
assert.ok(!f.calls().includes("STALE"));
});
// Step 4 is the one failure after which OpenRig itself is installed. The summary says so and that
// setup is not complete, keeps setup's own status, and names nothing setup's result doesn't establish:
// every incomplete setup (a sign-in still needed, a real failure beside it, an uncertain provider
// probe, a crash) reaches the wrapper only as a non-zero status, so it is never called a sign-in here.
for (const code of [1, 40]) test(`a rig setup exit ${code} reports the CLI installed and setup not complete, with status ${code}`, t => {
const f = fixture(t, { SETUP_EXIT: String(code) });
const r = f.run();
assert.equal(r.status, code, r.stderr);
assert.ok(f.calls().includes("installed:setup"));
assert.match(r.stdout, /setup native output/);
assert.match(r.stderr, /setup native error/);
assert.ok(!r.stderr.includes("FAILED"), r.stderr);
assert.ok(r.stderr.includes(`INSTALLED, SETUP NOT COMPLETE (exit ${code})`), r.stderr);
assert.ok(r.stderr.includes(`The OpenRig CLI is installed: ${path.join(f.prefix, "bin", "rig")}`), r.stderr);
assert.match(r.stderr, /Node\/SQLite compatibility check: passed\./);
assert.match(r.stderr, /"Some steps need attention", with a Fix where setup gives one, or shows the error that stopped it/);
assert.match(r.stderr, /run rig setup again; the CLI does not need reinstalling/);
assert.ok(!/sign[ -]?in|login/i.test(r.stderr.slice(r.stderr.indexOf("INSTALLED, SETUP NOT COMPLETE"))), "the summary claims no sign-in");
assert.ok(!r.stdout.includes("Follow the next steps"));
});
test("an incomplete setup after a skipped compatibility check says the check was skipped, not passed", t => {
const f = fixture(t, { SETUP_EXIT: "1" });
fs.unlinkSync(f.checker);
const r = f.run();
assert.equal(r.status, 1, r.stderr);
assert.match(r.stderr, /Node\/SQLite compatibility check: SKIPPED \(not checked; see above\)\./);
assert.ok(!r.stderr.includes("compatibility check: passed"), r.stderr);
});
test("a setup that finishes ready keeps today's closing line and no summary", t => {
const f = fixture(t);
const r = f.run();
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Follow the next steps printed by rig setup/);
assert.ok(!r.stderr.includes("SETUP NOT COMPLETE"), r.stderr);
});