// Rename design r2, stage B2: the command map. `mosaic org config validate|resolve` is the primary // spelling of `mosaic business validate|resolve`, and `mosaic org instances|instance|veto` of // `mosaic business instances|instance|veto`, with the same arguments. Both spellings call one // implementation: the installed-config service (packages/organization/src/config-command.mjs) and the // proof-bearing instances command (src/instances.mjs). These golden comparisons pin status, stdout, // stderr and authorization outcomes across the wrapper, the direct entry points and in-process run(). // The old spelling stays byte for byte; the new one differs only where the design lets help read // organization first: the stderr prefix of the config commands and the usage text. import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { chmodSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { BusError } from "../../bus/src/broker.mjs"; import { startBroker } from "../../bus/src/runtime.mjs"; import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../organization/tests/helpers.mjs"; import { projectFilePath } from "../../organization/src/index.mjs"; import { canonicalJson, sha256 } from "../../organization/src/util.mjs"; import { templateRender } from "../../organization/src/org-service.mjs"; import { BUSINESS_CONFIG, ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs"; import * as oldConfigCommand from "../../business/src/config-command.mjs"; import { run, USAGE } from "../src/cli.mjs"; import { bootConfig, loadSystem } from "../src/config.mjs"; import { INSTANCES_USAGE, ORG_INSTANCES_USAGE } from "../src/instances.mjs"; import { ORG_USAGE } from "../src/org.mjs"; import { fixture, io, tmp } from "./helpers.mjs"; const path = (p) => fileURLToPath(new URL(p, import.meta.url)); const MOSAIC = path("../../../scripts/mosaic"); const CLI = path("../src/cli.mjs"); const ORG_CLI = path("../../organization/src/cli.mjs"); const OLD_CLI = path("../../business/src/cli.mjs"); const CONFIG_COMMAND = path("../../organization/src/config-command.mjs"); const golden = (name) => readFileSync(path(`../../business/tests/fixtures/rename-a/${name}`), "utf8"); // The old spelling's stderr as the new spelling prints it: only a usage line changes. Every other // line, refusals and warnings included, is byte for byte the same. const OLD_USAGE_LINE = `mosaic business: ${BUSINESS_CONFIG.usage}`; const asOrgConfig = (stderr) => stderr.replaceAll(OLD_USAGE_LINE, `mosaic org config: ${ORG_CONFIG.usage}`); const spawn = (command, args, env, cwd) => { const p = spawnSync(command, args, { env, cwd, encoding: "utf8" }); assert.equal(p.error, undefined); return { status: p.status, stdout: p.stdout, stderr: p.stderr }; }; // An installed organization acme with its stack project file, as the stage A contract test builds it. function installed(t, edit = () => {}) { const root = tmp(t, "rb-"); const config = systemConfig(root), doc = businessDoc(root); writeJson(join(root, "config", "businesses", "acme.json"), doc); writeJson(projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } }); const env = { PATH: process.env.PATH, HOME: join(root, "home"), TMPDIR: process.env.TMPDIR, LANG: "C.UTF-8", MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) }; edit({ root, doc, env }); return { root, doc, env }; } // Every entry point for one argument list. Old: the wrapper, the old executable path and the moved // executable. New: the wrapper, the direct packages/cli executable and in-process run(). async function entries(args, env, cwd) { const x = io(env); const status = await run(["org", "config", ...args], x, {}); return { old: [spawn(MOSAIC, ["business", ...args], env, cwd), spawn(process.execPath, [OLD_CLI, ...args], env, cwd), spawn(process.execPath, [ORG_CLI, ...args], env, cwd)], now: [spawn(MOSAIC, ["org", "config", ...args], env, cwd), spawn(process.execPath, [CLI, "org", "config", ...args], env, cwd), { status, stdout: x.out.text, stderr: x.err.text }], }; } async function sameAcrossSpellings(args, env, cwd, status) { const { old, now } = await entries(args, env, cwd); const label = args.join(" ") || "(no arguments)"; for (const r of old.slice(1)) assert.deepEqual(r, old[0], `old entries agree: ${label}`); for (const r of now.slice(1)) assert.deepEqual(r, now[0], `new entries agree: ${label}`); assert.equal(old[0].status, status, `${label}: ${old[0].stderr}`); assert.deepEqual(now[0], { status: old[0].status, stdout: old[0].stdout, stderr: asOrgConfig(old[0].stderr) }, label); if (status !== 0) assert.equal(now[0].stdout, "", label); return { old: old[0], now: now[0] }; } test("rename r2 B2: org config validate|resolve has the status, stdout and stderr of business validate|resolve at every entry point", async (t) => { const { root, env } = installed(t); const cases = [ [["validate", "acme"], 0], [["resolve", "acme", "coder", "--project", "stack"], 0], [["resolve", "acme", "chief"], 0], [["validate", "missing"], 4], [["resolve", "acme", "unknown"], 2], [["resolve", "acme", "coder", "--project", "nope"], 2], [[], 4], [["bogus"], 4], [["validate"], 4], [["validate", "acme", "extra"], 4], [["resolve", "acme"], 4], [["resolve", "acme", "coder", "--project"], 4], [["resolve", "acme", "coder", "--project", "stack", "--project", "stack"], 4], ]; for (const [args, status] of cases) { const { old, now } = await sameAcrossSpellings(args, env, root, status); if (status === 4 && args[1] !== "missing") { assert.equal(old.stderr, `mosaic business: ${BUSINESS_CONFIG.usage}\n`); assert.equal(now.stderr, `mosaic org config: ${ORG_CONFIG.usage}\n`); } if (status === 0) assert.equal(old.stderr, ""); } // The old usage text is byte for byte what `mosaic business` printed before stage B. assert.equal(BUSINESS_CONFIG.usage, "usage: mosaic business validate | resolve [--project ]"); }); test("rename r2 B2: a credential problem, an expiry warning and a system config problem read the same in both spellings", async (t) => { const bad = installed(t, ({ root }) => chmodSync(join(root, "secrets", "coder-gitea.token"), 0o644)); for (const args of [["validate", "acme"], ["resolve", "acme", "coder"]]) { const { old, now } = await sameAcrossSpellings(args, bad.env, bad.root, 2); assert.equal(now.stderr, old.stderr, "a refusal is byte for byte the same, unnormalized"); assert.match(old.stderr, /^mosaic business: (?:roles\.coder\.credentials\.gitea|coder gitea) file: mode 644/m); assert.match(old.stderr, /credential reference problem\(s\)\n$/); } const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10); const warned = installed(t, ({ root, doc }) => { doc.roles.coder.credentials.vikunja.expires = soon; writeJson(join(root, "config", "businesses", "acme.json"), doc); }); const { old, now } = await sameAcrossSpellings(["resolve", "acme", "coder"], warned.env, warned.root, 0); assert.equal(now.stderr, old.stderr, "a warning is byte for byte the same, unnormalized"); assert.match(old.stderr, new RegExp(`^mosaic business: warning: .*expires on ${soon}\\n$`, "m")); const missing = installed(t, ({ root, env }) => { env.MOSAIC_CONFIG = join(root, "nowhere", "config.json"); }); const sys = await sameAcrossSpellings(["validate", "acme"], missing.env, missing.root, 3); assert.match(sys.old.stderr, /^mosaic business: system config problem \(mosaic-config exit 3\): /); assert.equal(sys.now.stderr, sys.old.stderr, "a system config problem is byte for byte the same, unnormalized"); }); test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and digest", async (t) => { const { root, env } = installed(t); const invoke = (args) => { const r = spawn(MOSAIC, ["org", "config", ...args], env, root); assert.deepEqual([r.status, r.stderr], [0, ""], args.join(" ")); return r.stdout; }; const validate = invoke(["validate", "acme"]); const resolved = invoke(["resolve", "acme", "coder", "--project", "stack"]); const { digest, ...preimage } = JSON.parse(resolved); assert.equal(sha256(canonicalJson(preimage)), digest); for (const [id, expected] of Object.entries(JSON.parse(validate).instances)) { const { digest: actual, ...record } = JSON.parse(invoke(["resolve", "acme", id])); assert.equal(sha256(canonicalJson(record)), actual, id); assert.equal(actual, expected, id); } const normalized = (text) => text.split(root).join("").replace(/[a-f0-9]{64}/g, ""); const preimageText = `${normalized(canonicalJson(preimage))}\n`; assert.equal(sha256(preimageText.slice(0, -1)), "0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6"); assert.equal(normalized(validate), golden("installed-validate.json")); assert.equal(normalized(resolved), golden("installed-resolve.json")); assert.equal(preimageText, golden("installed-preimage.json")); assert.equal(JSON.parse(validate).business, "acme", "the v1 key stays business"); }); test("rename r2 B2: a draft file named like an installed ID: org validate reads the file, org config validate the installed organization", async (t) => { const { root, env } = installed(t); const cwd = join(root, "drafts"); writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" })); const draft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd); assert.deepEqual(draft, { status: 0, stdout: "valid\n", stderr: "" }); const config = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd); assert.equal(config.status, 0, config.stderr); assert.equal(JSON.parse(config.stdout).file, join(root, "config", "businesses", "acme.json")); assert.deepEqual(config, spawn(MOSAIC, ["business", "validate", "acme"], env, cwd)); // Each side reads only its own: no draft, and org validate fails though acme is installed; // no installed acme, and org config validate fails though the draft is in the working directory. rmSync(join(cwd, "acme")); const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd); assert.equal(noDraft.status, 2); assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n"); writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" })); rmSync(join(root, "config", "businesses", "acme.json")); const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd); assert.equal(noInstalled.status, 4); assert.equal(noInstalled.stdout, ""); assert.match(noInstalled.stderr, /^mosaic business: .*acme/); assert.equal(spawn(MOSAIC, ["org", "validate", "acme"], env, cwd).stdout, "valid\n"); }); // A real broker over an installed organization, as instances.test.mjs builds it. Both spellings run // through run() with the same deps, so the proof-bearing transport and its calls are the same. async function bus(t) { const root = tmp(t, "rbi-"); const f = fixture(root, "acme"); const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }); const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization }); t.after(() => b.close()); const human = b.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }); const calls = []; const deps = { system: { dataRoot: f.dataRoot }, transport: (business) => async (verb, args = {}) => { calls.push({ business, verb, args }); try { return structuredClone(b.broker.request(human, { verb, args })); } catch (e) { if (!(e instanceof BusError)) throw e; const error = new Error(e.code); error.code = e.code; throw error; } }, }; const log = () => readFileSync(join(root, "config", "businesses", ".history", "acme", "changes.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); const mosaic = async (words, env = f.env) => { const x = io(env), before = calls.length; const code = await run(words, x, deps); return { code, out: x.out.text, err: x.err.text, calls: calls.slice(before) }; }; const revision = async () => JSON.parse((await mosaic(["business", "instances", "acme", "--json"])).out).revision; return { root, f, mosaic, revision, log }; } test("rename r2 B2: org instances|instance|veto reads, refuses and writes exactly as business instances|instance|veto", async (t) => { const s = await bus(t); for (const args of [["instances", "acme"], ["instances", "acme", "--json"]]) { const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]); assert.equal(old.code, 0, old.err); assert.deepEqual(now, old, args.join(" ")); assert.deepEqual(now.calls, [{ business: "acme", verb: "instances", args: {} }]); } // A refusal from the bus: the same code, reason and exit. for (const args of [ ["instance", "add", "acme", "coder", "--definition", "coder", "--revision", await s.revision()], ["veto", "acme", "keep", "coder", "--revision", "0".repeat(64)], ["instance", "set", "acme", "nobody", "--no-launch", "--revision", await s.revision()], ]) { const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]); assert.equal(old.code, 2, args.join(" ")); assert.match(old.err, /^mosaic: [a-z-]+: /); assert.deepEqual(now, old, args.join(" ")); } // An agent marker refuses before any call, in both spellings. for (const marker of ["MOSAIC_RUN_ID", "CODEX_THREAD_ID"]) { const env = { ...s.f.env, [marker]: "fixture-agent" }; const old = await s.mosaic(["business", "instances", "acme"], env), now = await s.mosaic(["org", "instances", "acme"], env); assert.equal(old.code, 3); assert.match(old.err, /outside any agent run/); assert.deepEqual(now, old); assert.deepEqual(now.calls, []); } // A write through the org spelling: the same lines, a log entry naming the human, and the old // spelling reads it back. const kept = await s.mosaic(["org", "veto", "acme", "keep", "coder", "--revision", await s.revision()]); assert.equal(kept.code, 0, kept.err); const entry = s.log().at(-1); assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`, "no restart: the launcher reads the change at its next launch check", ""]); assert.equal(entry.actor, "jason"); assert.deepEqual(kept.calls.map((c) => c.verb), ["veto.set"]); const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" }); const changed = await s.mosaic(["org", "instance", "set", "acme", "coder", "--vars", vars, "--no-launch", "--revision", await s.revision()]); assert.equal(changed.code, 0, changed.err); assert.match(changed.out, /^business acme: changed coder \(coder\)$/m); assert.deepEqual(changed.calls.at(-1).args, { revision: changed.calls.at(-1).args.revision, instance: "coder", op: "change", vars: { harness: "pi", model: "claude-sonnet-5-5" }, launch: false }); const json = JSON.parse((await s.mosaic(["business", "instances", "acme", "--json"])).out); assert.equal(json.instances.coder.kept, true); // The same error refusal the old spelling gives after the write: a kept instance refuses delete. const delOld = await s.mosaic(["business", "instance", "delete", "acme", "coder", "--revision", await s.revision()]); const delNew = await s.mosaic(["org", "instance", "delete", "acme", "coder", "--revision", await s.revision()]); assert.equal(delOld.code, 2); assert.deepEqual(delNew, delOld); }); test("rename r2 B2: org instances never reaches the dev-owner context, and bad arguments print the org usage", async (t) => { const s = await bus(t); // This fixture is not a named instance, so the dev-owner context refuses here; org instances runs. const show = await s.mosaic(["org", "show", "acme"]); assert.equal(show.code, 2); assert.match(show.err, /--dev-local-owner requires a named instance/); const view = await s.mosaic(["org", "instances", "acme"]); assert.equal(view.code, 0, view.err); assert.doesNotMatch(view.err, /dev-local-owner/); for (const args of [["instance"], ["instances"], ["veto", "acme"], ["instances", "acme", "--bogus"], ["instance", "add", "acme", "x"]]) { const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]); assert.deepEqual(old, { code: 4, out: "", err: `mosaic: ${INSTANCES_USAGE}\n`, calls: [] }, args.join(" ")); assert.deepEqual(now, { code: 4, out: "", err: `mosaic: ${ORG_INSTANCES_USAGE}\n`, calls: [] }, args.join(" ")); } assert.equal(ORG_INSTANCES_USAGE, INSTANCES_USAGE.replace(/^mosaic business /, "mosaic org ").replaceAll(" | business ", " | org ").replaceAll("BUSINESS", "ORG")); assert.doesNotMatch(ORG_INSTANCES_USAGE, /business|BUSINESS/); }); test("rename r2 B2: scripts/mosaic routes org instances|instance|veto to the proof-bearing command, not the dev-owner one", (t) => { const root = tmp(t, "rbw-"); const env = { PATH: process.env.PATH, HOME: join(root, "home"), LANG: "C.UTF-8", MOSAIC_CONFIG: join(root, "none.json") }; for (const sub of ["instance", "instances", "veto"]) { const old = spawn(MOSAIC, ["business", sub], env, root), now = spawn(MOSAIC, ["org", sub], env, root); assert.deepEqual(old, { status: 4, stdout: "", stderr: `mosaic: ${INSTANCES_USAGE}\n` }, sub); assert.deepEqual(now, { status: 4, stdout: "", stderr: `mosaic: ${ORG_INSTANCES_USAGE}\n` }, sub); } // A real read with an agent marker refuses with the human-shell check in both spellings. const agent = { ...env, MOSAIC_AGENT_RUN: "1" }; const old = spawn(MOSAIC, ["business", "instances", "acme"], agent, root), now = spawn(MOSAIC, ["org", "instances", "acme"], agent, root); assert.equal(old.status, 3); assert.match(old.stderr, /outside any agent run \(found MOSAIC_AGENT_RUN/); assert.deepEqual(now, old); // Without a marker and without a config, both fail the same way, and neither names the dev owner. const plainOld = spawn(MOSAIC, ["business", "instances", "acme"], env, root), plainNew = spawn(MOSAIC, ["org", "instances", "acme"], env, root); assert.notEqual(plainOld.status, 0); assert.deepEqual(plainNew, plainOld); assert.doesNotMatch(plainNew.stderr, /dev-local-owner/); }); test("rename r2 B2: importing the config service runs nothing and never exits; it returns the code", async (t) => { const p = spawnSync(process.execPath, ["--input-type=module", "-e", `const m = await import(${JSON.stringify(CONFIG_COMMAND)}); process.stdout.write(Object.keys(m).sort().join(",") + "\\n");`, "validate", "acme"], { encoding: "utf8", env: { PATH: process.env.PATH, LANG: "C.UTF-8" } }); assert.deepEqual([p.status, p.stdout, p.stderr], [0, "BUSINESS_CONFIG,ORG_CONFIG,runConfigCommand\n", ""]); assert.equal(oldConfigCommand.runConfigCommand, runConfigCommand); const root = tmp(t, "rbc-"); const out = { text: "" }, err = { text: "" }; const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } }; const env = { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: join(root, "none.json") }; assert.equal(runConfigCommand(["validate", "acme"], { ...streams, env, spelling: ORG_CONFIG }), 3); assert.equal(runConfigCommand([], { ...streams, env }), 4); assert.equal(out.text, ""); assert.match(err.text, /^mosaic business: system config problem /); assert.match(err.text, /\nmosaic business: usage: mosaic business validate \| resolve /); }); test("rename r2 B2: help lists the org spellings first and names the business spelling as the old one", () => { assert.match(ORG_USAGE, / \| validate FILE \| /, "org validate FILE stays the draft check"); assert.match(ORG_USAGE, / \| config validate ID \| config resolve ID INSTANCE \[--project ID\]$/); assert.ok(USAGE.includes(` ${ORG_USAGE}\n`)); assert.ok(USAGE.includes(` ${ORG_INSTANCES_USAGE}\n`)); assert.ok(!USAGE.includes(INSTANCES_USAGE)); assert.match(USAGE, /^ {2}mosaic business validate\|resolve\|instances\|instance\|veto: the old spelling of mosaic org config validate\|resolve and mosaic org instances\|instance\|veto, same arguments$/m); });