// Row 234 (#1740, r6 E4): `mosaic hosts status|check|remove`, and row 235 (#1741) revoke and sweep. A // fake transport records the calls; no bus // runs here. The bus side of each verb is in packages/bus/tests/grant-enroll.test.mjs. import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { main } from "../src/cli.mjs"; import { io, tmp } from "./helpers.mjs"; const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url)); const ROWS = [ { host: "web1", address: "10.1.1.53", port: 22, user: "deploy", sudo: true, state: "ready", hostKeys: ["SHA256:a"], installerFingerprint: "SHA256:b", checkedAt: "2026-10-21T00:00:00.000Z", hostLineLeft: [] }, { host: "web2", address: "10.1.1.54", port: 2222, user: "app", sudo: false, state: "pending-install", hostKeys: ["SHA256:c"], installerFingerprint: "SHA256:d", checkedAt: null, hostLineLeft: ["mosaic-" + "0".repeat(32)] }, ]; function deps(calls) { const answer = (who) => (business) => async (verb, args) => { calls.push({ who, business, verb, args }); if (verb === "hosts.status") return ROWS; if (verb === "hosts.check") return { host: args.host, state: "ready", checkedAt: "2026-10-21T00:00:00.000Z" }; return { host: args.host, removed: true }; }; return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") }; } test("row 234: hosts status prints one line per host, and --json the rows", async () => { const calls = []; const x = io(); await main(["hosts", "--business", "acme"], x, deps(calls)); assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "hosts.status", args: {} }]); assert.equal(x.out.text, "web1\tready\tdeploy@10.1.1.53:22\tsudo\t2026-10-21T00:00:00.000Z\n" + `web2\tpending-install\tapp@10.1.1.54:2222\tno-sudo\tunchecked\tline left: mosaic-${"0".repeat(32)}\n`); const y = io(); await main(["hosts", "status", "--json", "--tester", "--business", "acme"], y, deps(calls)); assert.deepEqual(JSON.parse(y.out.text), ROWS); assert.equal(calls[1].who, "tester"); }); test("row 234: hosts check and remove send the host id, as the human or the tester", async () => { const calls = []; const x = io(); await main(["hosts", "check", "web1", "--business", "acme"], x, deps(calls)); await main(["hosts", "remove", "web2", "--tester", "--business", "acme"], x, deps(calls)); assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [ { who: "human", verb: "hosts.check", args: { host: "web1" } }, { who: "tester", verb: "hosts.remove", args: { host: "web2" } }, ]); }); test("row 234: a malformed hosts command is a usage error and calls nothing", async () => { const calls = []; for (const argv of [["hosts", "check"], ["hosts", "remove"], ["hosts", "status", "web1"], ["hosts", "check", "web1", "web2"], ["hosts", "purge", "web1"], ["hosts", "check", "web1", "--json"], ["hosts", "--business"], ["hosts", "--force"]]) await assert.rejects(main([...argv], io(), deps(calls)), (e) => (assert.equal(e.exitCode, 4, argv.join(" ")), assert.match(e.message, /mosaic hosts/), true)); assert.equal(calls.length, 0); }); test("row 234: a bus refusal exits 2 with its code", async () => { const refusing = { system: { dataRoot: "/nonexistent" }, transport: () => async () => { throw new Error("host-in-use"); } }; await assert.rejects(main(["hosts", "remove", "web1", "--business", "acme"], io(), refusing), (e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /host-in-use/), true)); }); test("row 234: scripts/mosaic sends hosts to packages/cli, not the seat CLI", (t) => { const root = tmp(t, "mosaic-cli-hosts-route-"); const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") }; const r = spawnSync(MOSAIC, ["hosts", "purge", "web1"], { env, encoding: "utf8" }); assert.equal(r.status, 4); assert.match(r.stderr, /^mosaic: mosaic hosts \[status\]/); assert.doesNotMatch(r.stderr, /mosaic launch { const calls = []; const x = io(); await main(["hosts", "revoke", "ops", "web1", "--reason", "left the team", "--business", "acme"], x, deps(calls)); await main(["hosts", "sweep", "web1", "--tester", "--business", "acme"], x, deps(calls)); assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [ { who: "human", verb: "hosts.revoke", args: { agent: "ops", host: "web1", reason: "left the team" } }, { who: "tester", verb: "hosts.sweep", args: { host: "web1" } }, ]); }); test("row 235 (N10): revoke without --reason, --reason on another verb, and inherited names are usage errors", async () => { const calls = []; for (const argv of [["hosts", "revoke", "ops", "web1"], ["hosts", "revoke", "web1", "--reason", "r"], ["hosts", "revoke", "ops", "web1", "x", "--reason", "r"], ["hosts", "sweep"], ["hosts", "sweep", "web1", "web2"], ["hosts", "check", "web1", "--reason", "r"], ["hosts", "revoke", "ops", "web1", "--reason"], ["hosts", "revoke", "ops", "web1", "--reason", "a", "--reason", "b"], ["hosts", "constructor", "web1"], ["hosts", "toString", "web1"], ["hosts", "hasOwnProperty"], ["hosts", "__proto__", "web1"]]) await assert.rejects(main([...argv], io(), deps(calls)), (e) => (assert.equal(e.exitCode, 4, argv.join(" ")), assert.match(e.message, /mosaic hosts/), true)); assert.equal(calls.length, 0); }); test("row 235: a status line shows the tags of a revoke in progress", async () => { const rows = [{ ...ROWS[0], revoking: ["mosaic-" + "a".repeat(32)] }]; const x = io(); const d = { ...deps([]), transport: () => async () => rows }; await main(["hosts", "--business", "acme"], x, d); assert.equal(x.out.text, `web1\tready\tdeploy@10.1.1.53:22\tsudo\t2026-10-21T00:00:00.000Z\trevoking: mosaic-${"a".repeat(32)}\n`); });