// The organization selector on every CLI parser that takes --business (rename design r2, "Selector // scopes and parser matrix"): --org is the same option in that parser's scope. Each entry point runs // through main() with a stub transport that records the organization callFor binds, then stops; a // refused selector binds nothing. import { test } from "node:test"; import assert from "node:assert/strict"; import { main } from "../src/cli.mjs"; import { accessCommand } from "../src/access.mjs"; import { parseLimits } from "../src/limits.mjs"; import { parseArgs } from "../src/thread.mjs"; import { CliError } from "../src/errors.mjs"; import { OrgError } from "../../business/src/org-registry.mjs"; import { io, orgFixture, tmp } from "./helpers.mjs"; const STOP = "selector test stop"; const stop = () => { throw new CliError(STOP, 99); }; function stub(t) { const h = { bound: [], calls: [], prompted: 0 }; const transport = b => { h.bound.push(b); return async (verb, args) => { h.calls.push({ verb, args }); stop(); }; }; h.deps = { system: { dataRoot: tmp(t, "sel-") }, transport, testerTransport: transport, promptProxyKey: async () => { h.prompted++; stop(); }, }; return h; } // What one argv does: reached the bus (or the key prompt), refused, or failed some other way. async function outcome(t, argv, { tty = false, input } = {}) { const h = stub(t), term = io(); if (tty) { term.stdin.isTTY = true; term.stdout.isTTY = true; } if (input !== undefined) term.stdin.end(input); const seen = { bound: h.bound, calls: h.calls, get prompted() { return h.prompted; }, stdin: term.stdin }; try { await main(argv, term, h.deps); return { ...seen, kind: "done" }; } catch (e) { if (e instanceof CliError && e.message === STOP) return { ...seen, kind: "reached" }; if (e instanceof OrgError) return { ...seen, kind: "org", code: e.code }; if (e instanceof CliError) return { ...seen, kind: "refused", exitCode: e.exitCode, code: e.code, message: e.message }; throw e; } } // The design's per-parser cases. The selector goes after the verb's own arguments. const CASES = [ ["old alone", ["--business", "acme"], "acme"], ["new alone", ["--org", "acme"], "acme"], ["matching, old first", ["--business", "acme", "--org", "acme"], "acme"], ["matching, new first", ["--org", "acme", "--business", "acme"], "acme"], ["unequal, old first", ["--business", "acme", "--org", "beta"], "conflict"], ["unequal, new first", ["--org", "acme", "--business", "beta"], "conflict"], ["old repeated", ["--business", "acme", "--business", "acme"], "usage"], ["new repeated", ["--org", "acme", "--org", "acme"], "usage"], ["old missing", ["--business"], "usage"], ["new missing", ["--org"], "usage"], ["new empty", ["--org", ""], "usage"], ["new flag-shaped", ["--org", "--json"], "usage"], ["old flag-shaped", ["--business", "--org", "acme"], "usage"], ["new invalid", ["--org", "Acme"], "invalid"], ["old invalid", ["--business", "acme/../x"], "invalid"], ]; const ENTRIES = [ ["inbox"], ["decide", "d1234567", "yes"], ["tasks"], ["agents"], ["trail", "t1"], ["talk", "pm", "hi"], ["follow"], ["names"], ["name", "coder", "Bob"], ["halt"], ["resume"], ["processing", "status"], ["launches", "off"], ["launches", "on"], ["project", "list"], ["project", "create", "work"], ["threads"], ["thread", "read", "work/t1"], ["session", "list", "work/t1"], ["limits"], ["limits", "set", "--file", "patch.json"], ["usage"], ["plugins"], ["plugins", "set", "--file", "patch.json"], ["credentials", "status"], ["credentials", "delete", "coder", "--reason", "gone"], ["hosts", "status"], ["hosts", "revoke", "coder", "h1", "--reason", "gone"], ["grants", "status", "gitea"], ["grants", "revoke", "coder", "gitea", "--reason", "gone"], ]; function check(r, expected, label) { if (expected === "acme") { assert.deepEqual(r.bound, ["acme"], `${label}: ${r.kind} ${r.message ?? r.code ?? ""}`); return; } assert.deepEqual(r.bound, [], `${label} bound nothing`); assert.equal(r.calls.length, 0, `${label} made no call`); if (expected === "invalid") { assert.equal(r.kind, "org", label); assert.equal(r.code, "org-invalid", label); } else { assert.equal(r.kind, "refused", `${label}: ${r.kind}`); assert.equal(r.exitCode, 4, label); } } for (const base of ENTRIES) { test(`selector on mosaic ${base.join(" ")}`, async (t) => { for (const [name, selector, expected] of CASES) { const r = await outcome(t, [...base, ...selector]); check(r, expected, `${base.join(" ")} ${name}`); // The verbs whose usage error carries the reason say which spellings conflict. if (expected === "conflict" && !["limits", "usage", "plugins", "credentials", "hosts", "grants"].includes(base[0])) assert.match(r.message, /conflict/i, name); } }); } test("selector on mosaic proxy init: the key prompt waits for a valid selector", async (t) => { for (const [name, selector, expected] of CASES) { const r = await outcome(t, ["proxy", "init", ...selector], { tty: true }); check(r, expected, `proxy init ${name}`); assert.equal(r.prompted, expected === "acme" ? 1 : 0, `proxy init ${name} prompted`); if (r.kind === "refused") assert.equal(r.code, "proxy-usage", name); } // fetch takes no selector. assert.equal((await outcome(t, ["proxy", "fetch", "--org", "acme"])).code, "proxy-usage"); }); test("selector on mosaic access: the human proof binds the selected organization", async (t) => { for (const verb of [["list"], ["user", "add", "ana"]]) { for (const [name, selector, expected] of CASES) { const bound = [], term = io(); const deps = { system: { dataRoot: tmp(t, "sel-") }, transport: b => { bound.push(b); return async () => stop(); }, contextFor: () => { throw new Error("context read before the proof"); } }; let r; try { await accessCommand([...verb, ...selector], term, deps); r = { kind: "done" }; } catch (e) { if (e instanceof CliError && e.message === STOP) r = { kind: "reached" }; else if (e instanceof OrgError) r = { kind: "org", code: e.code }; else if (e instanceof CliError) r = { kind: "refused", exitCode: e.exitCode, message: e.message }; else throw e; } check({ ...r, bound, calls: [] }, expected, `access ${verb.join(" ")} ${name}`); if (expected === "conflict") assert.match(r.message, /conflict/i); } } }); test("a selector token taken as another option's value is that option's text", async (t) => { const note = await outcome(t, ["decide", "d1234567", "yes", "--note", "--org", "--business", "acme"]); assert.deepEqual(note.bound, ["acme"]); const project = await outcome(t, ["threads", "--project", "--org", "--business", "acme"]); assert.deepEqual(project.bound, ["acme"]); // The channel's first call reads plugins, so the repo shows in the shared parser's result. const repo = await outcome(t, ["project", "create", "work", "--repo", "--org", "--business", "acme"]); assert.deepEqual(repo.bound, ["acme"]); assert.deepEqual(parseArgs(["work", "--repo", "--org", "--business", "acme"], "u", { values: ["--repo", "--business"] }), { positional: ["work"], flags: new Set(), values: { "--repo": "--org", "--business": "acme" } }); for (const argv of [ ["credentials", "delete", "coder", "--reason", "--org", "--business", "acme"], ["hosts", "revoke", "coder", "h1", "--reason", "--business", "--org", "acme"], ["grants", "revoke", "coder", "gitea", "--reason", "--org", "--business", "acme"], ]) { const r = await outcome(t, argv); assert.deepEqual(r.bound, ["acme"], argv.join(" ")); assert.equal(r.calls[0].args.reason, argv[argv.indexOf("--reason") + 1], argv.join(" ")); } // callFor binds before the file is read, so the bound organization and the file name both show. const plugins = await outcome(t, ["plugins", "set", "--file", "--org", "--business", "acme"]); assert.deepEqual(plugins.bound, ["acme"]); assert.match(plugins.message, /--org/); assert.deepEqual(parseLimits(["set", "--file", "--org", "--business", "acme"]).values, { "--file": "--org", "--business": "acme" }); // proxy init: --port takes --org, so the port check refuses, not the selector. const port = await outcome(t, ["proxy", "init", "--port", "--org", "--business", "acme"], { tty: true }); assert.equal(port.code, "proxy-config-invalid"); // access keeps its own grammar: an option value may not start with --. await assert.rejects(accessCommand(["list", "--business", "acme", "--user", "--org"], io(), { transport: stop, contextFor: stop }), /--user needs one value/); }); test("positional text next to a selector stays positional", async (t) => { const r = await outcome(t, ["talk", "pm", "--org", "acme", "--business", "acme", "hi"]); assert.deepEqual(r.bound, ["acme"]); assert.deepEqual(r.calls[0], { verb: "message.send", args: { to: "pm", body: "hi", class: "REQUEST" } }); }); test("an invalid selector refuses before stdin is read", async (t) => { const argv = ["grants", "admin", "set", "gitea", "--url", "https://git.example", "--account", "a", "--rotate-by", "2027-01-01"]; const bad = await outcome(t, [...argv, "--org", "Bad"], { input: "tok" }); assert.equal(bad.code, "org-invalid"); assert.ok(bad.stdin.readableLength > 0, "the token is still unread"); const good = await outcome(t, [...argv, "--org", "acme"], { input: "tok" }); assert.equal(good.kind, "reached"); assert.deepEqual(good.bound, ["acme"]); assert.equal(good.calls[0].args.token, "tok"); }); test("--org is no longer a global option: a verb without a selector refuses it", async (t) => { for (const argv of [["console", "start", "--org", "acme"], ["bus", "status", "--org", "acme"]]) { const r = await outcome(t, argv); assert.equal(r.kind, "refused", argv.join(" ")); assert.match(r.message, /--org/, argv.join(" ")); } }); test("stop and launches list resolve the selected organization", async (t) => { const { f } = await orgFixture(t, "acme"); const run = async (argv) => { const term = io(f.env); try { await main(argv, term, { system: { dataRoot: f.dataRoot } }); return { kind: "done", out: term.out.text }; } catch (e) { if (e instanceof OrgError) return { kind: "org", code: e.code }; if (e instanceof CliError) return { kind: "refused", exitCode: e.exitCode, message: e.message.split("\n")[0] }; throw e; } }; for (const [name, selector, expected] of CASES) { const list = await run(["launches", "list", ...selector]); if (expected === "acme") assert.deepEqual(list, { kind: "done", out: "no sessions\n" }, name); else if (expected === "invalid") assert.deepEqual(list, { kind: "org", code: "org-invalid" }, name); else assert.equal(list.exitCode, 4, name); } const reference = await run(["stop", "r1", "--business", "acme"]); assert.notEqual(reference.kind, "org"); for (const selector of [["--org", "acme"], ["--org", "acme", "--business", "acme"]]) assert.deepEqual(await run(["stop", "r1", ...selector]), reference); assert.deepEqual(await run(["stop", "r1", "--org", "beta"]), { kind: "org", code: "org-unknown" }); assert.equal((await run(["stop", "r1", "--org", "acme", "--business", "beta"])).exitCode, 4); });