From 3baf6f2297fecbc6d733f67b83ecda807478802d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 8 Nov 2026 21:25:49 -0600 Subject: [PATCH] selector: --org and --business in case2 persona-import and precheck Rename design r2, "Selector scopes and parser matrix". case2-persona-import.mjs takes --org through selectorScope and case2-precheck.sh through scripts/selector.sh, one scope each. Both keep their exit codes and their old business-id wording; a repeated or conflicting selector says why. persona-import now checks the id while parsing, so stage refuses an invalid selector before it reads anything. The case2-install fake stack copy carries packages/business/src/selector.mjs, which the real copy already does (COPY_PATHS takes packages/business/src). --- packages/cli/tests/case2-install.test.mjs | 2 +- .../cli/tests/case2-persona-import.test.mjs | 59 +++++++++++++++++++ packages/cli/tests/case2-unit.test.mjs | 38 +++++++++++- scripts/case2-persona-import.mjs | 16 ++++- scripts/case2-precheck.sh | 17 +++++- 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index d901d2942..6509bc65a 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) { script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}' [ "$1" = copy ] && [ "$3" = --copies ] || exit 4 mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2" -for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/plugins/model-provider/cliproxy/index.mjs; do +for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1 done mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`); diff --git a/packages/cli/tests/case2-persona-import.test.mjs b/packages/cli/tests/case2-persona-import.test.mjs index 2eae71789..99f431291 100644 --- a/packages/cli/tests/case2-persona-import.test.mjs +++ b/packages/cli/tests/case2-persona-import.test.mjs @@ -398,3 +398,62 @@ test("usage: stage needs one of --staging and --dry-run, install takes only its assert.equal(run(["stage", "--bogus", "x"]).status, 2); assert.ok(!existsSync(s.staging)); }); + +// Rename design r2, "Selector scopes and parser matrix": --org and --business are one option in +// this script's own parser, run directly, not through case2-install.sh. +test("install takes --org or --business, alone or matching in either order", (t) => { + for (const selector of [["--business", "case2"], ["--org", "case2"], ["--business", "case2", "--org", "case2"], ["--org", "case2", "--business", "case2"]]) { + const s = scratch(t); + assert.equal(stage(s).status, 0); + const r = run(["install", "--staging", s.staging, "--config-dir", s.configDir, ...selector]); + assert.equal(r.status, 0, `${selector.join(" ")}: ${r.all}`); + assert.deepEqual(readdirSync(join(s.configDir, "personas")), ["case2"], selector.join(" ")); + assert.deepEqual(readdirSync(join(s.configDir, "personas", "case2")).sort(), ["bean.md", "chet.md"], selector.join(" ")); + } +}); + +test("a bad selector refuses before the staging directory or the Hermes home is read", (t) => { + const s = scratch(t); + assert.equal(stage(s).status, 0); + for (const [selector, status, err] of [ + [["--business", "case2", "--org", "other"], 2, "case2-persona-import: --business case2 and --org other conflict\n"], + [["--org", "case2", "--business", "other"], 2, "case2-persona-import: --org case2 and --business other conflict\n"], + [["--business", "case2", "--business", "case2"], 2, "case2-persona-import: --business given twice\n"], + [["--org", "case2", "--org", "case2"], 2, "case2-persona-import: --org given twice\n"], + [["--org"], 2, "case2-persona-import: --org needs one value\n"], + [["--business"], 2, "case2-persona-import: --business needs one value\n"], + [["--org", ""], 2, "case2-persona-import: --org needs one value\n"], + [["--org", "--dry-run"], 2, "case2-persona-import: --org needs one value, not --dry-run\n"], + [["--org", "-x"], 1, "case2-persona-import: refused: business -x isn't a business id\n"], + [["--org", "Case2"], 1, "case2-persona-import: refused: business Case2 isn't a business id\n"], + [["--business", "../x"], 1, "case2-persona-import: refused: business ../x isn't a business id\n"], + [["--org", "a.b"], 1, "case2-persona-import: refused: business a.b isn't a business id\n"], + ]) { + for (const verb of [["install", "--staging", s.staging, "--config-dir", s.configDir], ["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]]) { + rmSync(s.trace, { force: true }); + const r = run([...verb, ...selector], { trace: s.trace }); + const label = `${verb[0]} ${selector.join(" ")}`; + assert.equal(r.status, status, `${label}: ${r.all}`); + assert.equal(r.err, err, label); + assert.equal(r.out, "", label); + const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1)); + assert.deepEqual(opened.filter((p) => p.startsWith(s.root)), [], `${label} read nothing of the scratch tree`); + } + } + assert.equal(existsSync(join(s.configDir, "personas")), false); + // The control: the same trace sees install read the staging directory. + rmSync(s.trace, { force: true }); + assert.equal(install(s, "case2", { trace: s.trace }).status, 0); + assert.ok(readFileSync(s.trace, "utf8").includes(join(s.staging, "manifest.json"))); +}); + +test("a selector spelling is never another option's value, and stage still ignores a valid selector", (t) => { + const s = scratch(t); + // This grammar refuses any value starting "--", so a selector spelling can't be one. + const r = run(["install", "--staging", "--org", "case2", "--config-dir", s.configDir]); + assert.equal(r.status, 2, r.all); + assert.equal(r.err, "case2-persona-import: --staging needs a value\n"); + const st = stage(s, ["--org", "case2"]); + assert.equal(st.status, 0, st.all); + assert.deepEqual(readdirSync(s.staging).sort(), ["bean.md", "chet.md", "manifest.json"]); +}); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 6849e347f..fa8e8aaf4 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -736,8 +736,8 @@ function pathWithoutClaude(x) { return `${x.bin}:${usr}`; } -function precheck(x, { profile = "library", env = {}, path } = {}) { - const r = spawnSync(PRECHECK, ["--business", "acme", "--profile", profile], { +function precheck(x, { profile = "library", env = {}, path, args = ["--business", "acme", "--profile", profile] } = {}) { + const r = spawnSync(PRECHECK, args, { encoding: "utf8", timeout: 60000, env: { @@ -772,6 +772,40 @@ test("the precheck passes on a good host and prints free memory, without the key assert.doesNotMatch(r.out, /claude .*fake|marker/); }); +// The selector (rename design r2): --org and --business are one option in the precheck's scope, and a +// bad one refuses before any check line, with the script's usage exit 4. +test("the precheck takes --org or --business, alone or matching in either order, and refuses a bad selector before any check", (t) => { + const x = host(t); + for (const sel of [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]]) { + const r = precheck(x, { args: [...sel, "--profile", "library"] }); + assert.equal(r.code, 0, `${sel.join(" ")}: ${r.out}`); + assert.match(r.out, /ok {6}config validates/, sel.join(" ")); + } + const usage = "usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status\n"; + const notId = (v) => `case2-precheck: --business must be a business id: ${v}\n`; + for (const [args, out] of [ + [["--business", "acme", "--org", "beta"], "case2-precheck: --business acme and --org beta conflict\n"], + [["--org", "acme", "--business", "beta"], "case2-precheck: --org acme and --business beta conflict\n"], + [["--business", "acme", "--business", "acme"], "case2-precheck: --business given twice\n"], + [["--org", "acme", "--org", "acme"], "case2-precheck: --org given twice\n"], + [["--org"], usage], + [["--business"], usage], + [["--org", ""], usage], + [["--org", "--profile", "library"], notId("--profile")], + [["--business", "-x"], notId("-x")], + [["--org", "Acme"], notId("Acme")], + [["--org", "acme/../x"], notId("acme/../x")], + ]) { + const r = precheck(x, { args }); + assert.equal(r.code, 4, args.join(" ")); + assert.equal(r.out, out, args.join(" ")); + } + // A selector spelling taken as --profile's value is that option's text. + const r = precheck(x, { args: ["--profile", "--org", "--business", "acme"] }); + assert.equal(r.code, 4, r.out); + assert.equal(r.out, "case2-precheck: --profile must be factory or library: --org\n"); +}); + test("the precheck refuses each failed host check", (t) => { blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/); blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/); diff --git a/scripts/case2-persona-import.mjs b/scripts/case2-persona-import.mjs index fa614bad9..cd8fe8c5a 100755 --- a/scripts/case2-persona-import.mjs +++ b/scripts/case2-persona-import.mjs @@ -5,6 +5,7 @@ // case2-persona-import.mjs stage --hermes-root DIR --pair PROFILE=NAME --pair PROFILE=NAME // (--staging DIR | --dry-run) // case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID +// (--org ID is the same option) // // stage runs as whoever can read her Hermes home. Per profile it reads SOUL.md, the persona Hermes // loads (agent/prompt_builder.py:1829-1855 at df5700ebe), and config.yaml for the per-channel prompt @@ -31,6 +32,7 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { PERSONA_CHARS, personaTextFault } from "../packages/harness/src/bundle.mjs"; +import { selectorScope } from "../packages/business/src/selector.mjs"; // The tool names registered in hermes-agent's tools/*.py at df5700ebe (literal `name="..."` in // registry.register calls; the MCP and plugin tools get their names at run time), plus Hermes and @@ -67,9 +69,21 @@ class Usage extends Error {} function args(argv) { const out = { pairs: [] }; + // --org and --business are one selector in these options (rename design r2). A missing value or + // one starting "--" is a usage error, as before; an id outside this script's grammar is its + // refusal, now while parsing; a repeated or conflicting selector is a usage error. + let given; + const selector = selectorScope({ + valid: (id) => BUSINESS_ID.test(id), + refuse: (kind, why) => { + if (kind === "invalid" || (kind === "flag" && !given.startsWith("--"))) throw new Refusal(`business ${given} isn't a business id`); + throw new Usage(why); + }, + }); for (let i = 0; i < argv.length; i++) { const flag = argv[i]; if (flag === "--dry-run") { out.dryRun = true; continue; } + if (selector.owns(flag)) { given = argv[i + 1]; selector.take(flag, given); i++; continue; } const value = argv[i + 1]; if (value === undefined || value.startsWith("--")) throw new Usage(`${flag} needs a value`); i++; @@ -77,9 +91,9 @@ function args(argv) { else if (flag === "--hermes-root") out.hermesRoot = value; else if (flag === "--staging") out.staging = value; else if (flag === "--config-dir") out.configDir = value; - else if (flag === "--business") out.business = value; else throw new Usage(`unknown option ${flag}`); } + if (selector.given) out.business = selector.value; return out; } diff --git a/scripts/case2-precheck.sh b/scripts/case2-precheck.sh index 14036995e..34ff020bc 100755 --- a/scripts/case2-precheck.sh +++ b/scripts/case2-precheck.sh @@ -7,6 +7,8 @@ # case2-precheck.sh --business ID [--profile factory|library] # case2-precheck.sh --business ID --library-status # +# --org ID is the same option as --business ID. +# # Checks: the config and business file validate; `sudo -n true` fails and # `id -G` prints one group; bus.tester is off for library and on for factory, # the explicit notifier config is valid at 0600 in a private 0700 directory; @@ -53,11 +55,24 @@ WEBUI_UNIT="$UNIT_DIR/mosaic-webui.service" MARKER_TOKEN_CEILING=2000 usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status' >&2; exit 4; } +# shellcheck source=scripts/selector.sh +. "$REPO/scripts/selector.sh" +# --org and --business are one selector here (rename design r2). A missing value is a usage error, +# a flag-shaped or invalid one the business-id refusal below, and a repeated or conflicting one +# says why; all exit 4, as before. +selector_refused() { + case "$1" in + missing) usage ;; + flag|invalid) echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4 ;; + *) echo "case2-precheck: $2" >&2; exit 4 ;; + esac +} +business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; } PROFILE=library PROFILE_SET=0 BUSINESS='' STATUS=0 while [ $# -gt 0 ]; do case "$1" in --profile) [ $# -ge 2 ] || usage; PROFILE="$2"; PROFILE_SET=1; shift 2 ;; - --business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;; + --business|--org) BUSINESS="${2-}"; selector_take args id selector_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;; --library-status) STATUS=1; shift ;; *) usage ;; esac