selector: --org and --business in each CLI parser's own scope

Rename design r2, "Selector scopes and parser matrix". packages/business/src/selector.mjs
and scripts/selector.sh carry one rule set: in a parser's scope each spelling appears once,
and a second spelling must carry exactly the same value; a repeated spelling, a missing or
flag-shaped value, an invalid id and unequal values refuse. The file family
(--business-file/--org-file) compares paths as given.

Every CLI parser that took --business now takes --org through orgSelector, and the old
global --org rewrite in main() is gone, so a value another option consumes is never read as
a selector. The id is checked while parsing (OrgError org-invalid, exit 3), so grants admin
set no longer reads the token and proxy init no longer prompts for the key before an
invalid id refuses. access gains the same local id check.
This commit is contained in:
jason.woltje committed 2026-11-08 21:14:11 -06:00
1 parent 7d1ab7cd96
commit f198cfd47b
16 files changed
+538 -22

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
// Organization selector options (rename design r2, "Selector scopes and parser matrix").
// scripts/selector.sh is the shell half and keeps the same rules; packages/business/tests/
// selector.test.mjs runs one table against both.
//
// --org and --business are one option family, and --org-file and --business-file are another.
// A scope is one parser's own options: the parser that knows which tokens its other options
// consume calls take() for a selector token it meets, so a value or positional text that happens
// to read "--org" never reaches this file. Within one scope each spelling may appear once, and a
// second spelling must carry exactly the same value. A missing value, an empty one, one that
// starts with "-", an invalid one (by the parser's own predicate) and differing values refuse
// through the parser's own refuse function, so each entry point keeps its exit code and wording.
// The file family compares the strings given; its paths are checked later under existing rules.
export const SELECTOR_FAMILIES = Object.freeze({
id: Object.freeze({ canonical: '--business', spellings: Object.freeze(['--business', '--org']) }),
file: Object.freeze({ canonical: '--business-file', spellings: Object.freeze(['--business-file', '--org-file']) }),
});
// Each refusal names one of these kinds, so a parser can map it to its own usage error.
export const SELECTOR_REFUSALS = Object.freeze(['missing', 'flag', 'repeated', 'invalid', 'differ']);
export function isSelector(token, family = 'id') {
return SELECTOR_FAMILIES[family]?.spellings.includes(token) ?? false;
}
// refuse(kind, message) must throw; take() throws its own error if it returns.
export function selectorScope({ family = 'id', valid = null, refuse }) {
const spec = SELECTOR_FAMILIES[family];
if (!spec) throw new TypeError(`unknown selector family ${family}`);
if (typeof refuse !== 'function') throw new TypeError('selectorScope needs a refuse function');
const seen = [];
let value;
const fail = (kind, message) => {
refuse(kind, message);
throw new Error(`selector refusal returned: ${message}`);
};
return Object.freeze({
family,
canonical: spec.canonical,
owns: token => spec.spellings.includes(token),
take(flag, given) {
if (!spec.spellings.includes(flag)) throw new TypeError(`${flag} is not a ${family} selector`);
if (seen.includes(flag)) fail('repeated', `${flag} given twice`);
if (typeof given !== 'string' || given === '') fail('missing', `${flag} needs one value`);
if (given.startsWith('-')) fail('flag', `${flag} needs one value, not ${given}`);
if (valid && !valid(given)) fail('invalid', `${flag} ${given} is not valid`);
if (seen.length && given !== value) fail('differ', `${seen[0]} ${value} and ${flag} ${given} conflict`);
seen.push(flag);
value = given;
},
get value() { return value; },
get given() { return seen.length > 0; },
});
}
+138
View File
@@ -0,0 +1,138 @@
// The organization selector helpers: one table, run against packages/business/src/selector.mjs and
// scripts/selector.sh through the same small parser, so the two halves keep one set of rules.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { REPO } from './helpers.mjs';
import { ID_PATTERN } from '../src/vocabulary.mjs';
import { SELECTOR_FAMILIES, isSelector, selectorScope } from '../src/selector.mjs';
// A parser with one other value option (--note), positional text and a `--` separator.
function viaJs(family, args) {
const scope = selectorScope({ family, valid: family === 'id' ? id => ID_PATTERN.test(id) : null, refuse: kind => { throw Object.assign(new Error(kind), { kind }); } });
const out = { value: null, note: null, positional: [] };
try {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (scope.owns(a)) { scope.take(a, args[i + 1]); i++; }
else if (a === '--note') out.note = args[++i] ?? null;
else if (a === '--') { out.positional.push(...args.slice(i + 1)); break; }
else out.positional.push(a);
}
} catch (e) { if (e.kind) return { refused: e.kind }; throw e; }
out.value = scope.value ?? null;
return out;
}
const DRIVER = `
set -euo pipefail
. "$1"; family="$2"; shift 2
refuse() { printf 'refused %s\\n' "$1"; exit 3; }
valid() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
v=-; [ "$family" = id ] && v=valid
note=""; has_note=0; pos=()
while [ $# -gt 0 ]; do
case "$family:$1" in
id:--business|id:--org|file:--business-file|file:--org-file) selector_take s "$family" refuse "$v" "$1" "\${@:2:1}"; shift 2 ;;
*:--note) has_note=1; note="\${2-}"; shift; [ $# -gt 0 ] && shift ;;
*:--) shift; pos+=("$@"); break ;;
*) pos+=("$1"); shift ;;
esac
done
printf 'value %s\\n' "$(selector_value s)"
[ "$has_note" = 1 ] && printf 'note %s\\n' "$note"
for p in "\${pos[@]+"\${pos[@]}"}"; do printf 'pos %s\\n' "$p"; done
`;
function viaShell(family, args) {
const r = spawnSync('bash', ['-c', DRIVER, 'driver', join(REPO, 'scripts/selector.sh'), family, ...args], { encoding: 'utf8' });
if (r.status === 3) return { refused: /^refused (\S+)$/.exec(r.stdout.trim())[1] };
assert.equal(r.status, 0, r.stderr);
const out = { value: null, note: null, positional: [] };
for (const line of r.stdout.split('\n').filter(Boolean)) {
const [, key, rest] = /^(\S+) ?(.*)$/.exec(line);
if (key === 'value') out.value = rest || null;
else if (key === 'note') out.note = rest;
else out.positional.push(rest);
}
return out;
}
const ok = (value, more = {}) => ({ value, note: null, positional: [], ...more });
const ID_TABLE = [
['old alone', ['--business', 'acme'], ok('acme')],
['new alone', ['--org', 'acme'], ok('acme')],
['matching, old first', ['--business', 'acme', '--org', 'acme'], ok('acme')],
['matching, new first', ['--org', 'acme', '--business', 'acme'], ok('acme')],
['unequal, old first', ['--business', 'acme', '--org', 'beta'], { refused: 'differ' }],
['unequal, new first', ['--org', 'acme', '--business', 'beta'], { refused: 'differ' }],
['old repeated', ['--business', 'acme', '--business', 'acme'], { refused: 'repeated' }],
['new repeated', ['--org', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['new repeated after a match', ['--org', 'acme', '--business', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['old missing', ['--business'], { refused: 'missing' }],
['new missing', ['--org'], { refused: 'missing' }],
['new empty', ['--org', ''], { refused: 'missing' }],
['old flag-shaped', ['--business', '--org', 'acme'], { refused: 'flag' }],
['new flag-shaped', ['--org', '-x'], { refused: 'flag' }],
['old invalid', ['--business', 'Acme'], { refused: 'invalid' }],
['new invalid', ['--org', 'acme/../x'], { refused: 'invalid' }],
['equal but invalid', ['--org', 'A', '--business', 'A'], { refused: 'invalid' }],
['option-like value of another option', ['--note', '--org', '--business', 'acme'], ok('acme', { note: '--org' })],
['option-like value, new spelling', ['--note', '--business', '--org', 'acme'], ok('acme', { note: '--business' })],
['text after the separator', ['--org', 'acme', '--', '--business', 'beta', '--org'], ok('acme', { positional: ['--business', 'beta', '--org'] })],
['neither', ['word'], ok(null, { positional: ['word'] })],
];
const FILE_TABLE = [
['old alone', ['--business-file', 'a.json'], ok('a.json')],
['new alone', ['--org-file', 'a.json'], ok('a.json')],
['identical, old first', ['--business-file', 'a.json', '--org-file', 'a.json'], ok('a.json')],
['identical, new first', ['--org-file', 'a.json', '--business-file', 'a.json'], ok('a.json')],
['different paths, old first', ['--business-file', 'a.json', '--org-file', 'b.json'], { refused: 'differ' }],
['different paths, new first', ['--org-file', 'a.json', '--business-file', 'b.json'], { refused: 'differ' }],
// Strings are compared as given, so two spellings of one path differ.
['same file, other spelling', ['--org-file', 'a.json', '--business-file', './a.json'], { refused: 'differ' }],
['new repeated', ['--org-file', 'a.json', '--org-file', 'a.json'], { refused: 'repeated' }],
['old repeated', ['--business-file', 'a.json', '--business-file', 'a.json'], { refused: 'repeated' }],
['new missing', ['--org-file'], { refused: 'missing' }],
['new flag-shaped', ['--org-file', '--business-file', 'a.json'], { refused: 'flag' }],
['an id spelling is data here', ['--org', 'x', '--org-file', 'a.json'], ok('a.json', { positional: ['--org', 'x'] })],
];
for (const [family, table] of [['id', ID_TABLE], ['file', FILE_TABLE]]) {
for (const [name, args, expected] of table) {
test(`${family} selector: ${name}`, () => {
assert.deepEqual(viaJs(family, args), expected, 'selector.mjs');
assert.deepEqual(viaShell(family, args), expected, 'selector.sh');
});
}
}
test('selector families and spellings are fixed', () => {
assert.deepEqual(SELECTOR_FAMILIES.id.spellings, ['--business', '--org']);
assert.deepEqual(SELECTOR_FAMILIES.file.spellings, ['--business-file', '--org-file']);
assert.equal(isSelector('--org'), true);
assert.equal(isSelector('--org-file'), false);
assert.equal(isSelector('--org-file', 'file'), true);
assert.equal(isSelector('--organization'), false);
});
test('a refuse function that returns still refuses, and scopes are separate', () => {
const quiet = selectorScope({ refuse: () => {} });
assert.throws(() => quiet.take('--org', ''), /selector refusal returned/);
assert.throws(() => quiet.take('--org-file', 'x'), TypeError);
const refuse = kind => { throw new Error(kind); };
const outer = selectorScope({ refuse }), child = selectorScope({ refuse });
outer.take('--org', 'acme'); child.take('--org', 'beta');
assert.deepEqual([outer.value, child.value, outer.given], ['acme', 'beta', true]);
assert.equal(selectorScope({ refuse }).given, false);
});
test('the shell helper refuses a misuse and keeps scopes separate', () => {
const run = body => spawnSync('bash', ['-c', `set -u; . "$1"; refuse() { echo "refused $1"; exit 3; }; quiet() { :; }; ${body}`, 'x', join(REPO, 'scripts/selector.sh')], { encoding: 'utf8' });
assert.equal(run('selector_take s id refuse - --org-file a').status, 70);
assert.equal(run('selector_take bad-name id refuse - --org a').status, 70);
assert.equal(run('selector_take s id quiet - --org ""').status, 70);
const two = run('selector_take outer id refuse - --org acme; selector_take child id refuse - --business beta; echo "$(selector_value outer) $(selector_value child) [$(selector_value none)]"');
assert.equal(two.status, 0, two.stderr);
assert.equal(two.stdout, 'acme beta []\n');
});
+5
View File
@@ -13,6 +13,7 @@ import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { refuseInsideAgent } from "./transport.mjs";
export const ACCESS_USAGE = [
@@ -70,14 +71,18 @@ export function accessContextFor(system, env) {
// --tester is refused outright on every verb, before any bus call.
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(why => new CliError(`${why}\n${ACCESS_USAGE}`, 4));
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--json" || a === "--tester") out.flags.add(a);
else if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (a.startsWith("--")) {
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
out.values[a] = args[++i];
} else out.positional.push(a);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -8
View File
@@ -40,6 +40,7 @@ import { quitLine, runApp } from "./app.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../business/src/org-registry.mjs";
import { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStop } from "./org-console.mjs";
@@ -97,17 +98,22 @@ export const SEAT_USAGE = [
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
// A verb that takes --business also takes --org, in this parser's scope only, so a value another
// option consumes or positional text is never read as a selector (rename design r2).
function parse(args, { flags = [], values = [] }) {
const out = { positional: [], flags: new Set(), values: {} };
const selector = values.includes("--business") ? orgSelector(usage) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw usage(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw usage(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
@@ -671,13 +677,6 @@ async function app(io, deps) {
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
if (argv.length === 0) return app(io, deps);
if (argv[0] !== 'org') {
const org = argv.indexOf('--org'), business = argv.indexOf('--business');
if (org !== -1 && business !== -1) {
if (!argv[org + 1] || !argv[business + 1] || argv[org + 1] !== argv[business + 1]) throw usage('--org and --business conflict');
argv = argv.filter((_, i) => i !== org && i !== org + 1);
} else if (org !== -1) argv = argv.map((arg, i) => i === org ? '--business' : arg);
}
const [verb, ...rest] = argv;
if (verb === 'org') return orgCommand(rest, io, deps);
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
+7 -2
View File
@@ -3,6 +3,7 @@
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | issue|rotate AGENT host.ID --rotate-by DATE | " +
@@ -15,15 +16,19 @@ const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(CREDENTIALS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -6,6 +6,7 @@
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
// the human's, and the set is the human's Approve of the change.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
@@ -15,13 +16,16 @@ export const GRANTS_USAGE =
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
const MAX_TOKEN = 16384;
const MAX_SCOPE = 65536;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
const VALUES = ["--reason", "--url", "--account", "--rotate-by", "--revision"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(GRANTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
out.flags.add(arg);
} else if (VALUES.includes(arg)) {
@@ -30,6 +34,7 @@ function parse(args) {
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,21 +4,26 @@
// reads a key: the answers carry fingerprints only. Row 235 (#1741, r6 section 6, X3) adds revoke, which
// takes an agent's grant on a host away at once, and sweep, which removes the lines a failure left.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const HOSTS_USAGE =
"mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove|sweep HOST [--tester] [--business ID] | revoke AGENT HOST --reason R [--tester] [--business ID]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(HOSTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--business" || arg === "--reason") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--reason") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(HOSTS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(HOSTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { loadSystem } from "./config.mjs";
import { systemVars } from "../../business/src/index.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
@@ -34,15 +35,19 @@ export function formatTokens(tokens) {
export function parseLimits(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(LIMITS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(LIMITS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { configDir } from '../../business/src/business.mjs';
import { createProxyLock } from '../../bus/src/proxy-lock.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
@@ -60,11 +61,15 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
}
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
const parsed = { flags: new Set(), values: {} }; let port;
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => failure('proxy-usage', 4));
for (let i = 0; i < rest.length; i++) {
const flag = rest[i];
if (verb !== 'init' || !['--business', '--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
if (verb !== 'init' || !['--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
parsed.values[flag] = rest[++i];
}
if (selector.given) parsed.values['--business'] = selector.value;
if (parsed.values['--port'] !== undefined) {
if (!/^[0-9]+$/.test(parsed.values['--port'])) throw failure('proxy-config-invalid');
port = Number(parsed.values['--port']);
+17
View File
@@ -0,0 +1,17 @@
// The organization selector for the CLI's own parsers (rename design r2, "Selector scopes and
// parser matrix"): --org and --business in one parser's scope, under packages/business/src/
// selector.mjs. An id outside the organization grammar refuses as resolveOrg does, OrgError
// org-invalid with exit 3, but while parsing, so no verb reads stdin, prompts for a key or reads a
// business file first. Every other refusal is the parser's own usage error.
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { OrgError } from '../../business/src/org-registry.mjs';
import { selectorScope } from '../../business/src/selector.mjs';
// usageError(message) returns the parser's usage error for a missing, flag-shaped, repeated or
// conflicting selector.
export function orgSelector(usageError) {
return selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw kind === 'invalid' ? new OrgError('org-invalid', 'invalid organization id') : usageError(why); },
});
}
+7 -2
View File
@@ -5,20 +5,25 @@
// `tester`) in the organization's changes.jsonl.
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(PLUGINS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--file") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(PLUGINS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+6 -1
View File
@@ -7,6 +7,7 @@
// on the tester's own threads (owner `tester`, row 177). The broker's ownership check is the only one.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { shortId } from "./format.mjs";
import { configDir, loadBusiness } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
@@ -26,15 +27,19 @@ export const THREAD_TALK_USAGE = [
export function parseArgs(args, usage, { flags = [], values = [] } = {}) {
const out = { positional: [], flags: new Set(), values: {} };
const refuse = (why) => new CliError(`${why}\n${usage}`, 4);
// A command that takes --business also takes --org, in this parser's scope (rename design r2).
const selector = values.includes("--business") ? orgSelector(refuse) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw refuse(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw refuse(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
+5 -1
View File
@@ -1,13 +1,17 @@
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
export const USAGE_USAGE = 'mosaic usage [--business ID] [--json] [--tester]';
export async function usageCommand(args, io, callFor) {
const parsed = { flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(USAGE_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if ((arg === '--json' || arg === '--tester') && !parsed.flags.has(arg)) parsed.flags.add(arg);
else if (arg === '--business' && args[i + 1] && !args[i + 1].startsWith('--') && !parsed.values[arg]) parsed.values[arg] = args[++i];
else if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else throw new CliError(USAGE_USAGE, 4);
}
if (selector.given) parsed.values['--business'] = selector.value;
const result = await callFor(parsed)('usage', {});
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
const counts = row => `input=${row.input} output=${row.output} cacheWrite=${row.cacheWrite} cacheRead=${row.cacheRead} unknown=${row.unknownTurns}`;
+2 -1
View File
@@ -251,7 +251,8 @@ test("an agent caller is refused before the bus is asked", async (t) => {
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
const args = [...verb, "--business", "acme", "--tester"];
// GRANT already names the business; a repeated --business refuses (rename design r2).
const args = verb.includes("--business") ? [...verb, "--tester"] : [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
+211
View File
@@ -0,0 +1,211 @@
// 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);
});
+52
View File
@@ -0,0 +1,52 @@
# Sourced, not run: organization selector options, the shell half of
# packages/business/src/selector.mjs (rename design r2, "Selector scopes and parser matrix"). A
# script in the same checkout sources it; it defines functions only and runs no other program.
#
# --org and --business are one option family (id), and --org-file and --business-file are another
# (file). A scope is one parser's own options, so the caller's own loop decides which tokens are
# selectors; text after a separator or a value another option consumes is never passed here. In a
# scope each spelling may appear once, and a second spelling must carry exactly the same value.
#
# selector_take SCOPE FAMILY REFUSE VALID FLAG [VALUE]
# SCOPE [a-z0-9_]+, the scope's name; each keeps its own spellings and value
# FAMILY id or file
# REFUSE a function called as REFUSE KIND MESSAGE that must exit; KIND is missing, flag,
# repeated, invalid or differ, so each script keeps its exit codes and wording
# VALID a predicate called as VALID VALUE, or - when the caller checks the value itself
# FLAG the spelling given
# VALUE absent when the argument list ended there; pass "${@:2:1}" from a loop on "$@"
# selector_value SCOPE prints the scope's value, nothing when none was given
#
# A missing or empty value, one starting with "-", one VALID refuses, a repeated spelling and two
# spellings with different values all refuse. File values are compared as given, not as paths.
selector__bug() { echo "selector.sh: $*" >&2; exit 70; }
selector_take() {
[ $# -ge 5 ] && [ $# -le 6 ] || selector__bug "selector_take takes 5 or 6 arguments, not $#"
local scope="$1" family="$2" refuse="$3" valid="$4" flag="$5"
[[ "$scope" =~ ^[a-z0-9_]+$ ]] || selector__bug "bad scope name $scope"
case "$family:$flag" in
id:--business|id:--org|file:--business-file|file:--org-file) ;;
*) selector__bug "$flag is not a $family selector" ;;
esac
local seen_name="SELECTOR_SEEN_$scope" value_name="SELECTOR_VALUE_$scope"
local seen="${!seen_name-}" current="${!value_name-}" first
case " $seen " in *" $flag "*) "$refuse" repeated "$flag given twice"; selector__bug "$refuse returned" ;; esac
if [ $# -lt 6 ] || [ -z "$6" ]; then "$refuse" missing "$flag needs one value"; selector__bug "$refuse returned"; fi
local value="$6"
case "$value" in -*) "$refuse" flag "$flag needs one value, not $value"; selector__bug "$refuse returned" ;; esac
if [ "$valid" != - ] && ! "$valid" "$value"; then "$refuse" invalid "$flag $value is not valid"; selector__bug "$refuse returned"; fi
first="${seen%% *}"
if [ -n "$seen" ] && [ "$current" != "$value" ]; then
"$refuse" differ "$first $current and $flag $value conflict"; selector__bug "$refuse returned"
fi
printf -v "$seen_name" '%s' "${seen:+$seen }$flag"
printf -v "$value_name" '%s' "$value"
}
selector_value() {
[[ "${1-}" =~ ^[a-z0-9_]+$ ]] || selector__bug "bad scope name ${1-}"
local value_name="SELECTOR_VALUE_$1"
printf '%s' "${!value_name-}"
}