diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 0acd11cbe..982d66f23 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -23,6 +23,7 @@ import { createSso, readSlot } from './sso.mjs'; import { statSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; +import { selectorScope } from '../../business/src/selector.mjs'; const args = process.argv.slice(2); const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]'; try { @@ -30,8 +31,15 @@ try { if (args.shift() !== 'serve') throw new Error(usage); const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {}; let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false; + // --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped + // or invalid value keeps the old wording. + const selector = selectorScope({ + valid: id => ID_PATTERN.test(id), + refuse: (kind, why) => { throw new Error(kind === 'invalid' || kind === 'flag' ? 'business must be a business id' : why); }, + }); while (args.length) { const flag = args.shift(); + if (selector.owns(flag)) { selector.take(flag, args.shift()); continue; } // Row 76 (#1570): the one flag without a value. if (flag === '--send') { sendOn = true; continue; } // Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json. @@ -53,10 +61,6 @@ try { if (!dir) throw new Error('--checkout must be the absolute path of a directory'); options.checkout = value; } - else if (flag === '--business') { - if (!ID_PATTERN.test(value)) throw new Error('business must be a business id'); - options.business = value; - } // Row 74 (#1568): the preview mode, all three or none, checked before any config is read. else if (flag === '--public-host') preview.publicHost = value; else if (flag === '--auth-header') preview.authHeader = value; @@ -65,6 +69,7 @@ try { else if (flag === '--proof-file') proofFlags.proofFile = value; else throw new Error(`unknown option: ${flag}`); } + if (selector.given) options.business = selector.value; if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket'); const instance = devOwner ? devOwnerInstance() : null; // The local-development console command has no separate --send flag. diff --git a/packages/webui/tests/org-selector-cli.test.mjs b/packages/webui/tests/org-selector-cli.test.mjs new file mode 100644 index 000000000..4da1f4637 --- /dev/null +++ b/packages/webui/tests/org-selector-cli.test.mjs @@ -0,0 +1,67 @@ +// The organization selector on `cli.mjs serve` (rename design r2, "Selector scopes and parser +// matrix"): --org and --business are one option in serve's scope. serve.mjs and bus.mjs are +// stand-ins loaded through a hook, so nothing listens and no bus is called; the bus stand-in +// records the organization the Console would bind. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmp, fixture } from '../../cli/tests/helpers.mjs'; + +const CLI = new URL('../src/cli.mjs', import.meta.url).pathname; + +function serve(t, args) { + const root = tmp(t, 'sel-'), f = fixture(root), log = join(root, 'bound.jsonl'), hook = join(root, 'hooks.mjs'); + writeFileSync(log, ''); + const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(){return {address:()=>({port:0}),close:()=>{}};}"; + const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({business:o.business})+'\\n');return async()=>[];};export const organizationConsole=()=>{throw Error('organizationConsole outside dev-owner')};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`; + writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){ + if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}}; + if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}}; + return next(url,context); + }});`); + const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...args], { env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 }); + return { ...r, bound: readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l).business) }; +} + +const accepted = (r, name) => { + assert.equal(r.status, 0, `${name}: ${r.stderr}`); + assert.deepEqual(r.bound, ['acme'], name); + assert.match(r.stdout, /^Bus: acme$/m, name); +}; +const refused = (r, pattern, name) => { + assert.equal(r.status, 2, `${name}: ${r.stdout}${r.stderr}`); + assert.match(r.stderr, pattern, name); + assert.deepEqual(r.bound, [], `${name} bound nothing`); +}; + +test('serve takes --org or --business, alone or matching in either order', t => { + for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) accepted(serve(t, args), args.join(' ')); +}); + +test('serve refuses unequal, repeated, missing, flag-shaped and invalid selectors before any read', t => { + for (const [args, pattern] of [ + [['--business', 'acme', '--org', 'beta'], /^refused: --business acme and --org beta conflict/], + [['--org', 'acme', '--business', 'beta'], /^refused: --org acme and --business beta conflict/], + [['--business', 'acme', '--business', 'acme'], /^refused: --business given twice/], + [['--org', 'acme', '--org', 'acme'], /^refused: --org given twice/], + [['--org'], /^refused: --org needs one value/], + [['--business'], /^refused: --business needs one value/], + [['--org', ''], /^refused: --org needs one value/], + [['--org', '--send'], /^refused: business must be a business id/], + [['--business', '-x'], /^refused: business must be a business id/], + [['--org', 'Acme'], /^refused: business must be a business id/], + [['--org', 'acme/../x'], /^refused: business must be a business id/], + ]) refused(serve(t, args), pattern, args.join(' ')); +}); + +test('a selector spelling taken as another option\'s value is that option\'s text', t => { + const r = serve(t, ['--board', '--org', '--business', 'acme']); + accepted(r, 'board'); + assert.match(r.stdout, /^Board: --org$/m); + // Without a selector the Console keeps the running host's business. + const none = serve(t, []); + assert.equal(none.status, 0, none.stderr); + assert.deepEqual(none.bound, [null]); +}); diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index c5b017101..ee65630f0 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -258,6 +258,52 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => { } }); +// Rename design r2, "Selector scopes and parser matrix": --org and --business are one selector in +// the script's flags, and the unit always carries the canonical --business. +test('render takes --org or --business, alone or matching in either order, and writes --business', async (t) => { + const dir = tmp(t); + const want = `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7332 --business acme`; + for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) { + const r = await run(dir, ['render', ...args]); + assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); + assert.equal(execStart(r.stdout), want, args.join(' ')); + } + // A selector spelling another flag consumes is that flag's value, so the board check refuses it. + const board = await run(dir, ['render', '--board', '--org', '--business', 'acme']); + assert.equal(board.status, 1); + assert.match(board.stderr, /board/); +}); + +test('render, install, uninstall and status refuse a bad selector before anything is read or written', async (t) => { + const dir = tmp(t); + const unitDir = join(dir, 'units'); + const usageText = (await run(dir, [])).stderr; + for (const [args, status, stderr] of [ + [['--business', 'acme', '--org', 'beta'], 4, 'webui-service: --business acme and --org beta conflict\n'], + [['--org', 'acme', '--business', 'beta'], 4, 'webui-service: --org acme and --business beta conflict\n'], + [['--business', 'acme', '--business', 'acme'], 4, 'webui-service: --business given twice\n'], + [['--org', 'acme', '--org', 'acme'], 4, 'webui-service: --org given twice\n'], + [['--org'], 4, usageText], + [['--business'], 4, usageText], + [['--org', ''], 4, usageText], + [['--org', '--send'], 1, 'webui-service: business must be a business id: --send\n'], + [['--business', '-x'], 1, 'webui-service: business must be a business id: -x\n'], + [['--org', 'Acme'], 1, 'webui-service: business must be a business id: Acme\n'], + [['--org', 'a'.repeat(65)], 1, `webui-service: business must be a business id: ${'a'.repeat(65)}\n`], + [['--org', 'acme/../x'], 1, 'webui-service: business must be a business id: acme/../x\n'], + ]) { + for (const verb of [['render'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload'], ['uninstall', '--dir', unitDir, '--no-reload'], ['status', '--dir', unitDir]]) { + const r = await run(dir, [...verb, ...args]); + const label = `${verb[0]} ${args.join(' ')}`; + assert.equal(r.status, status, `${label}: ${r.stderr}`); + assert.equal(r.stdout, '', label); + assert.equal(r.stderr, stderr, label); + } + } + assert.equal(existsSync(unitDir), false, 'nothing was installed'); + assert.equal(existsSync(join(dir, '.quiet', 'calls.txt')), false, 'systemctl was never called'); +}); + test('the rendered unit names none of the human proof agent markers and sets no agent variable', async (t) => { const dir = tmp(t); const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8'); diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index 75d602318..7a8585a11 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -83,6 +83,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in" # shellcheck source=scripts/instance.sh . "$REPO/scripts/instance.sh" +# shellcheck source=scripts/selector.sh +. "$REPO/scripts/selector.sh" instance_args "$@" set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"} if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then @@ -99,6 +101,17 @@ USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-workt die() { echo "webui-service: $*" >&2; exit 1; } usage() { echo "$USAGE" >&2; exit 4; } +# --org and --business are one selector in these flags (rename design r2). A missing value is a +# usage error and a flag-shaped or invalid one the business-id refusal serve_flags makes, worded +# as before; a repeated or conflicting selector is a usage error that says why. +selector_refused() { + case "$1" in + missing) usage ;; + flag|invalid) die "business must be a business id: $BUSINESS" ;; + *) echo "webui-service: $2" >&2; exit 4 ;; + esac +} +business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; } UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies" @@ -135,7 +148,7 @@ parse_flags() { --port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;; --tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;; --tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;; - --business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;; + --business|--org) BUSINESS="${2-}"; selector_take flags id selector_refused business_id_ok "$1" "${@:2:1}"; SERVE_SET=1; shift 2 ;; --board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;; --public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;; --auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;