Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6ab5047af | ||
|
|
311ae46e3d | ||
|
|
d070ed6d4a | ||
|
|
87b7a34f1c | ||
|
|
a36d45af10 | ||
|
|
9a52eecffd | ||
|
|
4fa6995c13 | ||
|
|
381039fe5f | ||
|
|
1105de280b | ||
|
|
9e458878df | ||
|
|
226be2e935 | ||
|
|
dbb6909dcd | ||
|
|
4b7ddbcb43 | ||
|
|
7f882a905d | ||
|
|
3baf6f2297 | ||
|
|
259302a368 | ||
|
|
f198cfd47b | ||
|
|
500418e710 | ||
|
|
c2a6fad2a8 | ||
|
|
7d1ab7cd96 | ||
|
|
041e163d3c | ||
|
|
588253e524 | ||
|
|
93a73a7375 |
No files matched your search
@@ -4,8 +4,6 @@ import { randomBytes } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Client } from './client.mjs';
|
||||
import { readProcess } from './human.mjs';
|
||||
const file = fileURLToPath(import.meta.url);
|
||||
if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
|
||||
const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], {
|
||||
@@ -14,6 +12,11 @@ if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
|
||||
});
|
||||
process.exit(child.status ?? 2);
|
||||
}
|
||||
// P19 #876: only the re-executed child, whose initial environment holds the nonce, talks to the
|
||||
// bus. Loading the client (and the broker module graph behind it) here instead of at the top
|
||||
// keeps the re-exec parent to a bare node start.
|
||||
const { Client } = await import('./client.mjs');
|
||||
const { readProcess } = await import('./human.mjs');
|
||||
try {
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
if (
|
||||
|
||||
@@ -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; },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"business": "acme",
|
||||
"project": "stack",
|
||||
"instance": "coder",
|
||||
"definition": "coder",
|
||||
"holder": null,
|
||||
"contract": "<ROOT>/roles/coder.md",
|
||||
"vars": {
|
||||
"tracker.kind": "vikunja",
|
||||
"tracker.reconcileMinutes": 60,
|
||||
"tracker.pollSeconds": 60,
|
||||
"bus.tester": false,
|
||||
"tracker.descendants": false,
|
||||
"environment": "development",
|
||||
"dataRoot": "<ROOT>/data",
|
||||
"execution.backend": "docker",
|
||||
"execution.provider": "zai",
|
||||
"execution.model": "glm-5.3-flash",
|
||||
"execution.adapter": "mock",
|
||||
"tracker.baseUrl": "http://127.0.0.1:3456",
|
||||
"gitea.baseUrl": "https://git.example",
|
||||
"tracker.project": 3,
|
||||
"harness": "pi",
|
||||
"limits.network": "none"
|
||||
},
|
||||
"provenance": {
|
||||
"tracker.kind": "default",
|
||||
"tracker.reconcileMinutes": "default",
|
||||
"tracker.pollSeconds": "business:acme",
|
||||
"bus.tester": "default",
|
||||
"tracker.descendants": "default",
|
||||
"environment": "system",
|
||||
"dataRoot": "system",
|
||||
"execution.backend": "system",
|
||||
"execution.provider": "system",
|
||||
"execution.model": "system",
|
||||
"execution.adapter": "system",
|
||||
"tracker.baseUrl": "business:acme",
|
||||
"gitea.baseUrl": "business:acme",
|
||||
"tracker.project": "project:stack",
|
||||
"harness": "business:acme:roles.coder",
|
||||
"limits.network": [
|
||||
"business:acme:roles.coder"
|
||||
]
|
||||
},
|
||||
"limits": {
|
||||
"tools": [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
"ls"
|
||||
],
|
||||
"network": "none",
|
||||
"authority": {
|
||||
"withinRole": [
|
||||
"task.update.assigned",
|
||||
"git.push.working",
|
||||
"review.request",
|
||||
"message.send",
|
||||
"record.write"
|
||||
],
|
||||
"crossRole": [
|
||||
"task.scope.change"
|
||||
]
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"service": "gitea",
|
||||
"file": "<ROOT>/secrets/coder-gitea.token",
|
||||
"rotateBy": "2099-01-01"
|
||||
},
|
||||
"vikunja": {
|
||||
"service": "vikunja",
|
||||
"file": "<ROOT>/secrets/coder-vikunja.token",
|
||||
"expires": "2099-01-01"
|
||||
}
|
||||
},
|
||||
"tracker": {
|
||||
"bot": "bot-acme-coder",
|
||||
"botId": 5
|
||||
},
|
||||
"launch": null,
|
||||
"webSource": null,
|
||||
"skills": [
|
||||
"ms-unslop",
|
||||
"ms-sdlc",
|
||||
"ms-file-read",
|
||||
"ms-file-write"
|
||||
],
|
||||
"digest": "<DIGEST>"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"business": "acme",
|
||||
"file": "<ROOT>/config/businesses/acme.json",
|
||||
"projects": {
|
||||
"stack": "valid"
|
||||
},
|
||||
"instances": {
|
||||
"pm": "<DIGEST>",
|
||||
"cto": "<DIGEST>",
|
||||
"coder": "<DIGEST>",
|
||||
"reviewer": "<DIGEST>",
|
||||
"chief": "<DIGEST>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"business": "acme",
|
||||
"project": "stack",
|
||||
"instance": "coder",
|
||||
"definition": "coder",
|
||||
"holder": "example",
|
||||
"contract": "/synthetic/roles/coder.md",
|
||||
"vars": {
|
||||
"tracker.kind": "vikunja",
|
||||
"tracker.reconcileMinutes": 60,
|
||||
"tracker.pollSeconds": 15,
|
||||
"bus.tester": false,
|
||||
"tracker.descendants": false,
|
||||
"environment": "development",
|
||||
"dataRoot": "/synthetic/data",
|
||||
"execution.backend": "docker",
|
||||
"execution.provider": "zai",
|
||||
"execution.model": "system-model",
|
||||
"execution.adapter": "mock",
|
||||
"tracker.baseUrl": "http://127.0.0.1:3456",
|
||||
"tracker.project": 3,
|
||||
"model": "agent-model",
|
||||
"limits.network": "none"
|
||||
},
|
||||
"provenance": {
|
||||
"tracker.kind": "default",
|
||||
"tracker.reconcileMinutes": "default",
|
||||
"tracker.pollSeconds": "project:stack:roles.coder",
|
||||
"bus.tester": "default",
|
||||
"tracker.descendants": "default",
|
||||
"environment": "system",
|
||||
"dataRoot": "system",
|
||||
"execution.backend": "system",
|
||||
"execution.provider": "system",
|
||||
"execution.model": "system",
|
||||
"execution.adapter": "system",
|
||||
"tracker.baseUrl": "business:acme",
|
||||
"tracker.project": "project:stack",
|
||||
"model": "business:acme:roles.coder",
|
||||
"limits.network": [
|
||||
"business:acme:roles.coder"
|
||||
]
|
||||
},
|
||||
"limits": {
|
||||
"tools": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"network": "none",
|
||||
"authority": {
|
||||
"withinRole": [
|
||||
"message.send"
|
||||
],
|
||||
"crossRole": []
|
||||
}
|
||||
},
|
||||
"credentials": {},
|
||||
"tracker": null,
|
||||
"launch": null,
|
||||
"webSource": null,
|
||||
"skills": [],
|
||||
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Synthetic, path-stable resolver input; no file is read by resolveInstance.
|
||||
export const input = {
|
||||
system: { environment: 'development', dataRoot: '/synthetic/data', 'execution.backend': 'docker', 'execution.provider': 'zai', 'execution.model': 'system-model', 'execution.adapter': 'mock' },
|
||||
business: {
|
||||
id: 'acme', vars: { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'tracker.pollSeconds': 60 },
|
||||
projects: { stack: { root: '/synthetic/project' } },
|
||||
roles: { coder: { definition: 'coder', holder: 'example', vars: { model: 'agent-model', 'limits.network': 'none' }, credentials: {}, tracker: null } },
|
||||
definitions: { coder: { name: 'coder', tools: ['read', 'write'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] }, contractPath: '/synthetic/roles/coder.md', skills: [] } },
|
||||
},
|
||||
project: { id: 'stack', file: '/synthetic/project/.mosaic/project.json', vars: { 'tracker.project': 3, 'tracker.pollSeconds': 20 }, roles: { coder: { vars: { 'tracker.pollSeconds': 15 } } } },
|
||||
instance: 'coder',
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as publicApi from '../src/index.mjs';
|
||||
import * as direct from '../src/business.mjs';
|
||||
import { BusinessError, refuse } from '../src/errors.mjs';
|
||||
import { canonicalJson, sha256 } from '../src/util.mjs';
|
||||
import { validateRecord } from '../../project/src/validate.mjs';
|
||||
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
|
||||
import { input } from './fixtures/rename-a/resolver-input.mjs';
|
||||
|
||||
const fixture = name => readFileSync(new URL(`./fixtures/rename-a/${name}`, import.meta.url), 'utf8');
|
||||
|
||||
test('v1 resolver serialization, canonical preimage and digest are pinned', () => {
|
||||
const result = publicApi.resolveInstance(input);
|
||||
assert.equal(`${JSON.stringify(result, null, 2)}\n`, fixture('resolved.json'));
|
||||
const { digest, ...preimage } = result;
|
||||
const canonical = fixture('preimage.json');
|
||||
assert.equal(`${canonicalJson(preimage)}\n`, canonical);
|
||||
assert.equal(sha256(canonical.slice(0, -1)), digest);
|
||||
assert.equal(result.provenance['tracker.pollSeconds'], 'project:stack:roles.coder');
|
||||
assert.equal(result.provenance.model, 'business:acme:roles.coder');
|
||||
});
|
||||
|
||||
test('v1 business public and direct exports share identity and refusal constructor', () => {
|
||||
for (const name of ['configDir', 'businessFilePath', 'validateBusinessDocument', 'loadBusiness']) {
|
||||
assert.equal(publicApi[name], direct[name], name);
|
||||
}
|
||||
assert.equal(publicApi.BusinessError, BusinessError);
|
||||
assert.throws(() => refuse('fixture', 4), error => error instanceof publicApi.BusinessError && error.exitCode === 4 && error.message === 'fixture');
|
||||
});
|
||||
|
||||
test('v1 business document rejects missing, mixed and unknown version fields', t => {
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const doc = businessDoc(root);
|
||||
const check = value => publicApi.validateBusinessDocument(value, join(root, 'businesses', 'acme.json'), { rolesDir: REPO_ROLES });
|
||||
assert.equal(check(doc).id, 'acme');
|
||||
for (const change of [d => delete d.businessVersion, d => { d.businessVersion = 2; }, d => { d.businessVersion = '1'; }, d => { d.organizationVersion = 2; }, d => { d.unexpected = true; }]) {
|
||||
const candidate = structuredClone(doc); change(candidate);
|
||||
assert.throws(() => check(candidate), BusinessError);
|
||||
}
|
||||
});
|
||||
|
||||
test('v1 project layout requires its version and business identity exclusively', () => {
|
||||
const doc = { layoutVersion: 1, business: 'acme', project: 'stack', name: 'Stack', createdAt: '2026-01-01T00:00:00.000Z' };
|
||||
assert.deepEqual(validateRecord('project', doc), []);
|
||||
for (const change of [d => delete d.layoutVersion, d => { d.layoutVersion = 2; }, d => { d.layoutVersion = '1'; }, d => delete d.business, d => { d.organization = 'acme'; }, d => { d.unknown = true; }]) {
|
||||
const candidate = structuredClone(doc); change(candidate);
|
||||
assert.notDeepEqual(validateRecord('project', candidate), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('installed business validate and resolve have identical wrapper and direct responses', async t => {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const { businessDoc, REPO, rolesCopy, systemConfig, tmp, writeJson } = await import('./helpers.mjs');
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const config = systemConfig(root), doc = businessDoc(root);
|
||||
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
|
||||
writeJson(publicApi.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, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
|
||||
const directCli = join(REPO, 'packages/business/src/cli.mjs');
|
||||
const wrapper = join(REPO, 'scripts/mosaic');
|
||||
const invoke = (command, args) => {
|
||||
const p = spawnSync(command, args, { env, cwd: root, encoding: 'utf8' });
|
||||
assert.equal(p.error, undefined);
|
||||
return { status: p.status, stdout: p.stdout, stderr: p.stderr };
|
||||
};
|
||||
for (const args of [['validate', 'acme'], ['resolve', 'acme', 'coder', '--project', 'stack'], ['validate', 'missing'], ['resolve', 'acme', 'unknown']]) {
|
||||
const expected = invoke(process.execPath, [directCli, ...args]);
|
||||
const wrapped = invoke(wrapper, ['business', ...args]);
|
||||
assert.deepEqual(wrapped, expected, args.join(' '));
|
||||
assert.equal(expected.status, args[1] === 'missing' ? 4 : args[2] === 'unknown' ? 2 : 0);
|
||||
assert.doesNotMatch(expected.stdout + expected.stderr, /placeholder-not-a-token/);
|
||||
}
|
||||
});
|
||||
|
||||
test('org validate interprets an installed-ID-shaped argument as a draft filename', async t => {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const { templateRender } = await import('../src/org-service.mjs');
|
||||
const { REPO, writeJson } = await import('./helpers.mjs');
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
writeJson(join(root, 'acme'), await templateRender('acme', { fixture: 'local' }));
|
||||
const env = { PATH: process.env.PATH, HOME: root, TMPDIR: process.env.TMPDIR };
|
||||
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['org', 'validate', 'acme'], { cwd: root, env, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, 'valid\n');
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
|
||||
test('installed wrapper pins v1 validate and resolve output, preimage and digest', async t => {
|
||||
const { spawnSync } = await import('node:child_process');
|
||||
const { REPO, rolesCopy, systemConfig, writeJson } = await import('./helpers.mjs');
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const config = systemConfig(root), doc = businessDoc(root);
|
||||
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
|
||||
writeJson(publicApi.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, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
|
||||
const invoke = args => {
|
||||
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['business', ...args], { env, cwd: root, encoding: 'utf8' });
|
||||
assert.deepEqual([result.status, result.stderr], [0, '']);
|
||||
return result.stdout;
|
||||
};
|
||||
const validate = invoke(['validate', 'acme']);
|
||||
const parsed = JSON.parse(validate);
|
||||
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(parsed.instances)) {
|
||||
const { digest: actual, ...record } = JSON.parse(invoke(['resolve', 'acme', id]));
|
||||
assert.equal(sha256(canonicalJson(record)), actual);
|
||||
assert.equal(actual, expected);
|
||||
}
|
||||
const normalized = text => text.split(root).join('<ROOT>').replace(/[a-f0-9]{64}/g, '<DIGEST>');
|
||||
const snapshots = {
|
||||
'installed-validate.json': normalized(validate),
|
||||
'installed-resolve.json': normalized(resolved),
|
||||
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
|
||||
};
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
|
||||
for (const [name, actual] of Object.entries(snapshots)) {
|
||||
assert.equal(actual, fixture(name), name);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, consoleStatus, 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;
|
||||
}
|
||||
|
||||
@@ -682,13 +688,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);
|
||||
|
||||
@@ -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,9 +7,10 @@
|
||||
// so a launch runs the real launcher, sandbox, runner and pi against it.
|
||||
// Refuses: the default dev instance, an instance with a proxy configured, a
|
||||
// provider credential in the environment, a non-loopback endpoint and a second
|
||||
// fixture. Exit codes as errors.mjs: 2 invalid, 3 refused, 4 usage.
|
||||
// fixture. A record whose endpoint is gone (its lifetime ended, or it crashed)
|
||||
// is stale: init replaces it. Exit codes as errors.mjs: 2 invalid, 3 refused, 4 usage.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { get } from 'node:http';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -19,9 +20,10 @@ import { loadSystem } from './config.mjs';
|
||||
import { parseArgs } from './thread.mjs';
|
||||
import { isProviderVariable } from '../../seat/src/session.mjs';
|
||||
import { FIXTURE_ENTRY, FIXTURE_KEY, FIXTURE_KEY_NAME, FIXTURE_MODEL } from '../../plugins/model-provider/fixture/index.mjs';
|
||||
import { TTL_DEFAULT_S, TTL_MAX_S, TTL_MIN_S } from '../../plugins/model-provider/fixture/endpoint.mjs';
|
||||
|
||||
export const ENDPOINT = resolve(dirname(fileURLToPath(import.meta.url)), '../../plugins/model-provider/fixture/endpoint.mjs');
|
||||
export const DEV_USAGE = `mosaic dev fixture init --model deterministic [--instance NAME] [--bind 127.0.0.1] [--json]
|
||||
export const DEV_USAGE = `mosaic dev fixture init --model deterministic [--instance NAME] [--bind 127.0.0.1] [--ttl SECONDS] [--json]
|
||||
mosaic dev fixture status [--json]
|
||||
mosaic dev fixture stop [--json]`;
|
||||
export const fixtureDir = dataRoot => join(dataRoot, 'dev-fixture');
|
||||
@@ -93,11 +95,21 @@ function instanceOf(io, parsed) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
function startEndpoint(logFile, { timeoutMs = 10000 } = {}) {
|
||||
// --ttl: the endpoint's lifetime in whole seconds, TTL_DEFAULT_S when absent. 0 is no expiry,
|
||||
// for a long-running dev instance; it is safe only because this command runs on a named dev
|
||||
// instance and nothing else starts the endpoint with 0.
|
||||
function ttlOf(raw) {
|
||||
if (raw === undefined) return TTL_DEFAULT_S;
|
||||
const n = /^(0|[1-9][0-9]{0,6})$/.test(raw) ? Number(raw) : NaN;
|
||||
if (n === 0 || (n >= TTL_MIN_S && n <= TTL_MAX_S)) return n;
|
||||
throw invalid(`--ttl takes 0 (no expiry) or whole seconds from ${TTL_MIN_S} to ${TTL_MAX_S}`);
|
||||
}
|
||||
|
||||
function startEndpoint(logFile, ttlSeconds, { timeoutMs = 10000 } = {}) {
|
||||
const log = openSync(logFile, 'a', 0o600);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [ENDPOINT], { detached: true, stdio: ['ignore', 'pipe', log], env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } });
|
||||
child = spawn(process.execPath, [ENDPOINT], { detached: true, stdio: ['ignore', 'pipe', log], env: { PATH: process.env.PATH ?? '/usr/bin:/bin', MOSAIC_FIXTURE_TTL_S: String(ttlSeconds) } });
|
||||
} finally { closeSync(log); }
|
||||
return new Promise((resolve, reject) => {
|
||||
let out = '';
|
||||
@@ -114,32 +126,56 @@ function startEndpoint(logFile, { timeoutMs = 10000 } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the stale record this init inspected, and only that one. The record is taken by an
|
||||
// atomic rename, then checked: if a concurrent init already replaced it with a live endpoint's
|
||||
// record, that record goes back (link refuses to overwrite) and this init refuses fixture-exists.
|
||||
// An unlink by path after the check would delete the other init's new record instead.
|
||||
export function takeStale(dataRoot, existing, instance) {
|
||||
const file = fixtureFile(dataRoot), aside = `${file}.stale-${process.pid}`;
|
||||
try { renameSync(file, aside); } catch (e) { if (e.code === 'ENOENT') return; throw e; }
|
||||
let taken = null;
|
||||
try { taken = JSON.parse(readFileSync(aside, 'utf8')); } catch {}
|
||||
if (taken?.pid === existing.pid && taken?.startedAt === existing.startedAt) { rmSync(aside, { force: true }); return; }
|
||||
// EEXIST means a third init published meanwhile; that record stays and this init still refuses.
|
||||
try { linkSync(aside, file); } catch {} finally { rmSync(aside, { force: true }); }
|
||||
throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
|
||||
}
|
||||
|
||||
const print = (io, json, value, line) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${line}\n`);
|
||||
|
||||
async function init(args, io, deps) {
|
||||
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--model', '--instance', '--bind'] });
|
||||
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--model', '--instance', '--bind', '--ttl'] });
|
||||
if (parsed.positional.length) throw invalid('fixture init takes no positional arguments');
|
||||
if (parsed.values['--model'] !== 'deterministic') throw invalid('fixture init needs --model deterministic');
|
||||
const ttlSeconds = ttlOf(parsed.values['--ttl']);
|
||||
const bind = parsed.values['--bind'] ?? '127.0.0.1';
|
||||
if (bind !== '127.0.0.1') throw refuse('fixture-endpoint-not-loopback', `the fixture binds 127.0.0.1 only, not ${bind}`);
|
||||
const instance = instanceOf(io, parsed);
|
||||
noCredentials(io.env);
|
||||
const system = deps.system ?? loadSystem({ env: io.env, instance });
|
||||
if (system.proxy) throw refuse('fixture-proxy-configured', `instance ${instance} has a model proxy configured; the fixture is for an instance without one`);
|
||||
if (readFixture(system.dataRoot)) throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
|
||||
const existing = readFixture(system.dataRoot);
|
||||
if (existing && isEndpoint(existing.pid)) throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
|
||||
// P19 #882: an endpoint whose lifetime ended left its record, and init refused fixture-exists
|
||||
// until `dev fixture stop`. A record whose pid is no longer the endpoint is stale; it goes.
|
||||
// A second init racing this one still loses at the exclusive write of the new record.
|
||||
if (existing) takeStale(system.dataRoot, existing, instance);
|
||||
const replaced = existing ? { pid: existing.pid, startedAt: existing.startedAt ?? null } : null;
|
||||
try { mkdirSync(fixtureDir(system.dataRoot), { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; }
|
||||
ownedPrivate(fixtureDir(system.dataRoot), true);
|
||||
const { child, port } = await startEndpoint(join(fixtureDir(system.dataRoot), 'endpoint.log'));
|
||||
const { child, port } = await startEndpoint(join(fixtureDir(system.dataRoot), 'endpoint.log'), ttlSeconds);
|
||||
const endpoint = `http://127.0.0.1:${port}`;
|
||||
const started = new Date();
|
||||
const record = { fixtureVersion: 1, fixture: 'fixture', model: 'deterministic', modelKey: FIXTURE_KEY_NAME, entry: FIXTURE_ENTRY,
|
||||
instance, endpoint, port, pid: child.pid, principal: PRINCIPAL, upstream: 'none', startedAt: new Date().toISOString() };
|
||||
instance, endpoint, port, pid: child.pid, principal: PRINCIPAL, upstream: 'none', startedAt: started.toISOString(),
|
||||
ttlSeconds, expiresAt: ttlSeconds ? new Date(started.getTime() + ttlSeconds * 1000).toISOString() : null };
|
||||
try {
|
||||
if (!(await health(endpoint))?.ok) throw refuse('fixture-start-failed', 'the endpoint did not answer its health check');
|
||||
writeFileSync(fixtureFile(system.dataRoot), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||
} catch (e) { try { process.kill(child.pid, 'SIGTERM'); } catch {} throw e; }
|
||||
const receipt = { receipt: 'dev-fixture-init', ...record, loopback: true, external: false, state: fixtureFile(system.dataRoot) };
|
||||
const receipt = { receipt: 'dev-fixture-init', ...record, replaced, loopback: true, external: false, state: fixtureFile(system.dataRoot) };
|
||||
print(io, parsed.flags.has('--json'), receipt,
|
||||
`fixture ready: instance ${instance}, model ${FIXTURE_MODEL} (launch.models key ${FIXTURE_KEY_NAME}), endpoint ${endpoint} (loopback, no upstream), principal ${PRINCIPAL}, pid ${child.pid}`);
|
||||
`fixture ready: instance ${instance}, model ${FIXTURE_MODEL} (launch.models key ${FIXTURE_KEY_NAME}), endpoint ${endpoint} (loopback, no upstream), principal ${PRINCIPAL}, pid ${child.pid}, ${ttlSeconds ? `expires ${record.expiresAt}` : 'no expiry'}${replaced ? `; replaced the stale record of pid ${replaced.pid}` : ''}`);
|
||||
}
|
||||
|
||||
async function status(args, io, deps) {
|
||||
@@ -153,8 +189,8 @@ async function status(args, io, deps) {
|
||||
const answer = alive ? await health(record.endpoint) : null;
|
||||
const report = { instance, ready: Boolean(alive && answer?.ok && !system.proxy), endpoint: record.endpoint, loopback: true, upstream: 'none', external: false,
|
||||
model: record.model, modelKey: record.modelKey, entry: record.entry, principal: record.principal, pid: record.pid, alive,
|
||||
proxyConfigured: Boolean(system.proxy), health: answer, startedAt: record.startedAt };
|
||||
print(io, parsed.flags.has('--json'), report, `fixture ${report.ready ? 'ready' : 'not ready'}: ${record.endpoint}, pid ${record.pid}${alive ? '' : ' (not running)'}`);
|
||||
proxyConfigured: Boolean(system.proxy), health: answer, startedAt: record.startedAt, ttlSeconds: record.ttlSeconds ?? null, expiresAt: record.expiresAt ?? null };
|
||||
print(io, parsed.flags.has('--json'), report, `fixture ${report.ready ? 'ready' : 'not ready'}: ${record.endpoint}, pid ${record.pid}${alive ? '' : ' (not running; init replaces the record)'}`);
|
||||
}
|
||||
|
||||
async function stop(args, io, deps) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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); },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -508,3 +509,12 @@ test("R4 the help keeps the provider-removal order and says what a refused reloa
|
||||
assert.match(text, /grant and revoke ask the bus to re-read the file after the write\. If the bus refuses, the write still stands \(exit 0\), stderr names the refusal, and the bus's timer applies the change within 30 s\./);
|
||||
assert.ok(text.indexOf("so do that second") < text.indexOf("re-read the file"), "next to the provider-removal order");
|
||||
});
|
||||
|
||||
test('v1 grant receipt preserves business identity and exact JSON line shape', async t => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, '--revision', '1'], s.deps);
|
||||
const raw = s.receipts();
|
||||
const grant = s.read().grants[0].id;
|
||||
assert.match(grant, /^g-[0-9a-f]{8}$/);
|
||||
assert.equal(raw, JSON.stringify({ at: '2026-10-20T20:00:00.000Z', verb: 'grant', business: 'acme', user: 'alice', actor: 'human', revisionBefore: 1, revisionAfter: 2, grants: [grant] }) + '\n');
|
||||
});
|
||||
@@ -437,6 +437,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
if(fault==='inner'||fault==='outer'){
|
||||
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
|
||||
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
@@ -783,6 +784,101 @@ test("row 195 accept: usage errors and a run without --root as a user exit 2", (
|
||||
assert.ok(!existsSync(join(h.hc, "calls.log")));
|
||||
});
|
||||
|
||||
// The selector (rename design r2): --org and --business are one option in each of three scopes, the
|
||||
// script's own arguments and the args file's unit and serve arguments either side of --. A spelling
|
||||
// may appear once per scope, and the other spelling only with the same value; never counted across --.
|
||||
const SELECT = { old: ["--business", "mosaic-stack"], new: ["--org", "mosaic-stack"],
|
||||
both: ["--business", "mosaic-stack", "--org", "mosaic-stack"], swapped: ["--org", "mosaic-stack", "--business", "mosaic-stack"] };
|
||||
// The args file with outer unit selector tokens and serve selector tokens in ARGS's places.
|
||||
const argsWith = (outer, serve) => [...ARGS.slice(0, 6), ...outer, ...ARGS.slice(8, 16), "--port", "732", ...serve, "--tester-socket"];
|
||||
const invocation = (h, sel) => [...sel, "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root];
|
||||
|
||||
test("row 195 accept: --org or --business names the business in each of the three scopes, and talk and precheck still get --business", (t) => {
|
||||
const runs = [["old", "old", "old"], ["new", "new", "new"], ["both", "swapped", "both"], ["swapped", "both", "swapped"],
|
||||
["new", "old", "new"], ["old", "new", "old"], ["both", "old", "swapped"]];
|
||||
const seen = [];
|
||||
for (const [inv, outer, serve] of runs) {
|
||||
const h = host(t), name = `${inv} ${outer} -- ${serve}`;
|
||||
put(h.args, argsWith(SELECT[outer], SELECT[serve]).join("\n") + "\n", 0o644);
|
||||
const r = run(h, {}, invocation(h, SELECT[inv]));
|
||||
assert.equal(r.status, 0, `${name}: ${r.out}${r.err}`);
|
||||
assert.deepEqual(failed(r), [], name);
|
||||
const talk = calls(h, "mosaic").filter((a) => a.startsWith("talk "));
|
||||
assert.equal(talk.length, 1, name);
|
||||
assert.match(talk[0], /^talk chief case1-accept-[0-9a-f]{12}: .* --business mosaic-stack --tester --wait 300$/, name);
|
||||
assert.deepEqual(calls(h, "precheck"), ["--business mosaic-stack --profile factory"], name);
|
||||
seen.push(r.lines.filter((l) => /^[FP][0-9]+ /.test(l)).map((l) => l.split(" ").slice(0, 3).join(" ")));
|
||||
}
|
||||
for (const marks of seen) assert.deepEqual(marks, seen[0]);
|
||||
});
|
||||
|
||||
test("row 195 accept: a bad selector in any scope, or scopes that name different businesses, refuse with exit 2 before any check", (t) => {
|
||||
const usage = "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N\n";
|
||||
const refusing = (why) => `case1-accept: refusing: ${why}\n`;
|
||||
const before = (why) => refusing(`/etc/mosaic-stack/dev.args before --: ${why}`);
|
||||
const after = (why) => refusing(`/etc/mosaic-stack/dev.args after --: ${why}`);
|
||||
const cases = [
|
||||
// The script's own arguments.
|
||||
["invocation unequal", ["--business", "mosaic-stack", "--org", "beta"], null, refusing("--business mosaic-stack and --org beta conflict")],
|
||||
["invocation unequal, new first", ["--org", "mosaic-stack", "--business", "beta"], null, refusing("--org mosaic-stack and --business beta conflict")],
|
||||
["invocation old twice", ["--business", "mosaic-stack", "--business", "mosaic-stack"], null, refusing("--business given twice")],
|
||||
["invocation new twice", ["--org", "mosaic-stack", "--org", "mosaic-stack"], null, refusing("--org given twice")],
|
||||
["invocation new empty", ["--org", ""], null, usage],
|
||||
["invocation new flag-shaped", ["--org", "--args"], null, usage],
|
||||
["invocation new invalid", ["--org", "Mosaic"], null, usage],
|
||||
["invocation old invalid", ["--business", "a/../b"], null, usage],
|
||||
// The args file's unit arguments.
|
||||
["unit unequal", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--org", "beta"], SELECT.old), before("--business mosaic-stack and --org beta conflict")],
|
||||
["unit unequal, new first", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--business", "beta"], SELECT.old), before("--org mosaic-stack and --business beta conflict")],
|
||||
["unit old twice", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--business", "mosaic-stack"], SELECT.old), before("--business given twice")],
|
||||
["unit new twice", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--org", "mosaic-stack"], SELECT.new), before("--org given twice")],
|
||||
["unit new missing", SELECT.old, [...ARGS.slice(0, 6), ...ARGS.slice(8, 15), "--org"], before("--org needs one value")],
|
||||
["unit new flag-shaped", SELECT.old, argsWith(["--org"], SELECT.old), before("--org needs one value, not --copy")],
|
||||
["unit new invalid", SELECT.old, argsWith(["--org", "Mosaic"], SELECT.old), before("--org Mosaic is not valid")],
|
||||
["unit names another business", SELECT.new, argsWith(["--org", "beta"], SELECT.new), refusing("/etc/mosaic-stack/dev.args is for business beta, not mosaic-stack")],
|
||||
// --home takes --org as its value, so --business is the unit's one selector and the home refuses.
|
||||
["unit --home value", SELECT.old, argsWith(["--business", "mosaic-stack"], SELECT.old).map((a, i) => (i === 5 ? "--org" : a)),
|
||||
refusing("/etc/mosaic-stack/dev.args lacks a plain --user, --home, --business, --node, --claude or --copy")],
|
||||
// The args file's serve arguments.
|
||||
["serve unequal", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--org", "beta"]), after("--business mosaic-stack and --org beta conflict")],
|
||||
["serve unequal, new first", SELECT.old, argsWith(SELECT.old, ["--org", "mosaic-stack", "--business", "beta"]), after("--org mosaic-stack and --business beta conflict")],
|
||||
["serve old twice", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--business", "mosaic-stack"]), after("--business given twice")],
|
||||
["serve new twice", SELECT.old, argsWith(SELECT.new, ["--org", "mosaic-stack", "--org", "mosaic-stack"]), after("--org given twice")],
|
||||
["serve new missing", SELECT.old, [...argsWith(SELECT.old, []), "--org"], after("--org needs one value")],
|
||||
["serve new flag-shaped", SELECT.old, argsWith(SELECT.old, ["--org"]), after("--org needs one value, not --tester-socket")],
|
||||
["serve new invalid", SELECT.old, argsWith(SELECT.old, ["--org", "Mosaic"]), after("--org Mosaic is not valid")],
|
||||
["serve names another business", SELECT.new, argsWith(SELECT.new, ["--org", "beta"]), refusing("serve business differs")],
|
||||
["serve has no selector", SELECT.old, argsWith(SELECT.old, []), refusing("/etc/mosaic-stack/dev.args needs --business or --org after --")],
|
||||
// --port takes --org as its value; the serve selector is the --business after it.
|
||||
["serve --port value", SELECT.old, [...argsWith(SELECT.old, []).slice(0, 17), "--org", "--business", "mosaic-stack", "--tester-socket"],
|
||||
refusing("/etc/mosaic-stack/dev.args lacks a privileged numeric --port after --")],
|
||||
];
|
||||
for (const [name, sel, file, err] of cases) {
|
||||
const h = host(t);
|
||||
if (file) put(h.args, file.join("\n") + "\n", 0o644);
|
||||
const r = run(h, {}, invocation(h, sel));
|
||||
assert.equal(r.status, 2, `${name}: ${r.out}${r.err}`);
|
||||
assert.equal(r.err, err, name);
|
||||
assert.equal(r.out, "", name);
|
||||
assert.ok(!existsSync(join(h.hc, "calls.log")), name);
|
||||
}
|
||||
// A missing value at the end of the script's own arguments is a usage error.
|
||||
const h = host(t);
|
||||
const r = run(h, {}, ["--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root, "--org"]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.equal(r.err, usage);
|
||||
});
|
||||
|
||||
test("row 195 accept: a copy of the script without selector.sh beside it refuses before parsing", (t) => {
|
||||
const h = host(t), lone = join(h.dir, "lone", "case1-accept.sh");
|
||||
put(lone, read(SCRIPT), 0o755);
|
||||
const r = run(h, {}, undefined, lone);
|
||||
assert.equal(r.status, 2, r.out + r.err);
|
||||
assert.ok(r.err.endsWith(`case1-accept: refusing: cannot read ${join(h.dir, "lone", "selector.sh")}\n`), r.err);
|
||||
assert.equal(r.out, "");
|
||||
assert.ok(!existsSync(join(h.hc, "calls.log")));
|
||||
});
|
||||
|
||||
test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env PATH=<node dir>:...", (t) => {
|
||||
const h = host(t);
|
||||
const r = run(h);
|
||||
|
||||
@@ -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"`);
|
||||
@@ -250,6 +250,64 @@ test("the install refuses root, a business id mismatch, another --home and a con
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
// The selectors (rename design r2): --org/--business and --org-file/--business-file are two
|
||||
// families, each its own scope. A bad selector refuses with exit 4 before anything is read or written.
|
||||
const rest = (x) => ["--commit", COMMIT, "--profile", "library", "--personas", x.staging];
|
||||
|
||||
test("the install takes --org and --org-file, alone or matching their old spelling in either order", (t) => {
|
||||
for (const sel of [
|
||||
(f) => ["--org", "acme", "--org-file", f],
|
||||
(f) => ["--business", "acme", "--org", "acme", "--business-file", f, "--org-file", f],
|
||||
(f) => ["--org", "acme", "--business", "acme", "--org-file", f, "--business-file", f],
|
||||
]) {
|
||||
const x = world(t);
|
||||
const r = install(x, { args: [...sel(x.input), ...rest(x)] });
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.equal(JSON.parse(readFileSync(x.business, "utf8")).id, "acme");
|
||||
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the install refuses a bad selector in either family before it reads or writes anything", (t) => {
|
||||
const x = world(t);
|
||||
const f = x.input, g = `${x.root}/input/../input/acme.json`; // join() would normalize it to f
|
||||
assert.notEqual(g, f);
|
||||
const usage = "usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]\n";
|
||||
const notId = (v) => `case2-install: --business must be a business id: ${v}\n`;
|
||||
for (const [args, out] of [
|
||||
[["--business", "acme", "--org", "beta", "--business-file", f], "case2-install: --business acme and --org beta conflict\n"],
|
||||
[["--org", "acme", "--business", "beta", "--business-file", f], "case2-install: --org acme and --business beta conflict\n"],
|
||||
[["--business", "acme", "--business", "acme", "--business-file", f], "case2-install: --business given twice\n"],
|
||||
[["--org", "acme", "--org", "acme", "--business-file", f], "case2-install: --org given twice\n"],
|
||||
[["--business-file", f, "--org"], usage],
|
||||
[["--business-file", f, "--business"], usage],
|
||||
[["--org", "", "--business-file", f], usage],
|
||||
[["--org", "--business-file", f], notId("--business-file")],
|
||||
[["--business", "-x", "--business-file", f], notId("-x")],
|
||||
[["--org", "Acme", "--business-file", f], notId("Acme")],
|
||||
[["--org", "a.b", "--business-file", f], notId("a.b")],
|
||||
// The file family compares the text given: the same file named another way still conflicts.
|
||||
[["--org", "acme", "--business-file", f, "--org-file", g], `case2-install: --business-file ${f} and --org-file ${g} conflict\n`],
|
||||
[["--org", "acme", "--org-file", g, "--business-file", f], `case2-install: --org-file ${g} and --business-file ${f} conflict\n`],
|
||||
[["--org", "acme", "--org-file", f, "--org-file", f], "case2-install: --org-file given twice\n"],
|
||||
[["--org", "acme", "--business-file", f, "--business-file", f], "case2-install: --business-file given twice\n"],
|
||||
[["--org", "acme", "--org-file"], usage],
|
||||
[["--org", "acme", "--org-file", ""], usage],
|
||||
[["--org", "acme", "--org-file", "--commit", COMMIT], "case2-install: --org-file needs one value, not --commit\n"],
|
||||
]) {
|
||||
const last = ["--business-file", "--business", "--org", "--org-file"].includes(args.at(-1));
|
||||
const r = install(x, { args: last ? args : [...args, ...rest(x)] });
|
||||
assert.equal(r.code, 4, args.join(" "));
|
||||
assert.equal(r.out, out, args.join(" "));
|
||||
}
|
||||
// A selector spelling taken as another option's value is that option's text.
|
||||
const r = install(x, { args: ["--org", "acme", "--business-file", f, "--commit", "--org", "--profile", "library", "--personas", x.staging] });
|
||||
assert.equal(r.code, 4, r.out);
|
||||
assert.equal(r.out, "case2-install: --commit must be a full 40-character commit id: --org\n");
|
||||
assert.equal(calls(x), "");
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
test("the library install needs --personas", (t) => {
|
||||
const x = world(t);
|
||||
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
|
||||
@@ -433,7 +491,7 @@ function dependencyFreeClone(t) {
|
||||
const x = world(t);
|
||||
const clone = join(x.root, "clone");
|
||||
mkdirSync(join(clone, "scripts"), { recursive: true });
|
||||
for (const file of ["case2-install.sh", "case2-persona-import.mjs"]) {
|
||||
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
|
||||
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
|
||||
}
|
||||
// Isolate the persona step; bootstrap and business validation are covered above.
|
||||
@@ -463,6 +521,16 @@ test("row 322 F8: library personas install from the copy with no clone node_modu
|
||||
assert.equal(existsSync(join(x.clone, "node_modules")), false);
|
||||
});
|
||||
|
||||
// Without selector.sh beside it the install refuses before parsing, rather than skip the selector rules.
|
||||
test("the install refuses when its selector.sh is missing, before it reads or writes anything", (t) => {
|
||||
const x = dependencyFreeClone(t);
|
||||
rmSync(join(x.clone, "scripts", "selector.sh"));
|
||||
const r = x.run();
|
||||
assert.equal(r.code, 4, r.out);
|
||||
assert.ok(r.out.endsWith(`case2-install: cannot read ${join(x.clone, "scripts", "selector.sh")}\n`), r.out);
|
||||
assert.equal(existsSync(x.business), false);
|
||||
});
|
||||
|
||||
test("row 322 F9: importer refusal keeps the error line rather than the Node banner", (t) => {
|
||||
const x = dependencyFreeClone(t);
|
||||
const copy = join(x.home, ".local", "share", "mosaic-stack", "copies", COMMIT);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -107,9 +107,9 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
|
||||
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
|
||||
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
|
||||
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {} } = {}) {
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
|
||||
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
|
||||
const args = ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
|
||||
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
|
||||
const r = spawnSync(UNIT, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
@@ -338,6 +338,71 @@ test("a clean read-only library render writes both units 0644 with the fixed lin
|
||||
]);
|
||||
});
|
||||
|
||||
// The selector (rename design r2): --org and --business are one option in each of two scopes, the
|
||||
// script's own flags before -- and the serve flags after it. Nothing is counted across --; the serve
|
||||
// selector is required, must be the unit's business, and renders as --business.
|
||||
const SELECTORS = [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]];
|
||||
|
||||
test("the unit takes --org or --business on either side of --, renders --business once, and re-installs as the same units", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme"] }).code, 0);
|
||||
const before = [read(busFile(h)), read(webuiFile(h))];
|
||||
assert.deepEqual(lines(before[1], "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme`]);
|
||||
for (const outer of SELECTORS) {
|
||||
for (const child of SELECTORS) {
|
||||
const r = unit(h, { profile: "factory", outer, flags: ["--port", "443", ...child] });
|
||||
assert.equal(r.code, 0, `${outer.join(" ")} -- ${child.join(" ")}: ${r.err}`);
|
||||
assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before, `${outer.join(" ")} -- ${child.join(" ")}`);
|
||||
}
|
||||
}
|
||||
// The selector keeps its place among the serve flags.
|
||||
const g = hub(t);
|
||||
assert.equal(unit(g, { profile: "factory", outer: ["--org", "acme"], flags: ["--org", "acme", "--port", "443", "--business", "acme"] }).code, 0);
|
||||
assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --business acme --port 443`]);
|
||||
});
|
||||
|
||||
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
|
||||
const h = hub(t);
|
||||
const serve = ["--port", "443", "--business", "acme"];
|
||||
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
|
||||
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
|
||||
for (const [label, argv, code, err] of [
|
||||
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
|
||||
["outer unequal, new first", [...head, "--org", "acme", "--business", "beta", "--", ...serve], 4, "case2-unit: --org acme and --business beta conflict\n"],
|
||||
["outer old repeated", [...head, "--business", "acme", "--business", "acme", "--", ...serve], 4, "case2-unit: --business given twice\n"],
|
||||
["outer new repeated", [...head, "--org", "acme", "--org", "acme", "--", ...serve], 4, "case2-unit: --org given twice\n"],
|
||||
["outer missing", [...head, "--org"], 4, usage],
|
||||
["outer empty", [...head, "--org", "", "--", ...serve], 4, usage],
|
||||
["outer none", [...head, "--", ...serve], 4, usage],
|
||||
["outer invalid", [...head, "--org", "Acme", "--", ...serve], 1, "case2-unit: --business must be a business id: Acme\n"],
|
||||
["outer flag-shaped", [...head, "--business", "-x", "--", ...serve], 1, "case2-unit: --business must be a business id: -x\n"],
|
||||
["outer takes no separator", [...head, "--org", "--", ...serve], 1, "case2-unit: --business must be a business id: --\n"],
|
||||
["serve unequal, old first", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--org", "beta"], 1, "case2-unit: serve --business acme and --org beta conflict\n"],
|
||||
["serve unequal, new first", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--business", "beta"], 1, "case2-unit: serve --org acme and --business beta conflict\n"],
|
||||
["serve old repeated", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--business", "acme"], 1, "case2-unit: serve flag --business given twice\n"],
|
||||
["serve new repeated", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--org", "acme"], 1, "case2-unit: serve flag --org given twice\n"],
|
||||
["serve missing", [...head, "--business", "acme", "--", "--port", "443", "--org"], 1, "case2-unit: serve flag --org needs a value\n"],
|
||||
["serve empty", [...head, "--business", "acme", "--", "--port", "443", "--org", ""], 1, "case2-unit: serve flag --org needs a value\n"],
|
||||
["serve none", [...head, "--org", "acme", "--", "--port", "443"], 1, "case2-unit: serve flag --business is required\n"],
|
||||
["serve invalid", [...head, "--business", "acme", "--", "--port", "443", "--org", "Acme"], 1, "case2-unit: serve --org Acme is not the unit's business acme\n"],
|
||||
["serve flag-shaped", [...head, "--business", "acme", "--", "--port", "443", "--org", "--tester-socket"], 1, "case2-unit: serve --org --tester-socket is not the unit's business acme\n"],
|
||||
["outer and serve differ", [...head, "--org", "acme", "--", "--port", "443", "--org", "beta"], 1, "case2-unit: serve --org beta is not the unit's business acme\n"],
|
||||
["serve and outer differ", [...head, "--org", "beta", "--", "--port", "443", "--business", "acme"], 1, "case2-unit: serve --business acme is not the unit's business beta\n"],
|
||||
["a selector as --copy's value", ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", "--org", "--node", USR_NODE, "--profile", "factory", "--", ...serve], 1, "case2-unit: --copy must be a full 40-character lower-case commit id: --org\n"],
|
||||
]) {
|
||||
const r = unit(h, { argv });
|
||||
assert.equal(r.code, code, `${label}: ${r.err}`);
|
||||
assert.equal(r.err, err, label);
|
||||
assert.equal(r.out, "", label);
|
||||
}
|
||||
// A selector spelling taken as a library serve flag's value is that flag's text.
|
||||
const library = libraryFlags(h).map((f) => (f === "books.example" ? "--org" : f));
|
||||
refuses(unit(h, { flags: library }), /^case2-unit: public host must be a hostname, no port or scheme: --org\n$/);
|
||||
assert.equal(existsSync(busFile(h)), false);
|
||||
assert.equal(existsSync(webuiFile(h)), false);
|
||||
assert.equal(existsSync(h.log), false);
|
||||
});
|
||||
|
||||
test("the read-only library profile explicitly refuses --send before writing a unit", (t) => {
|
||||
const h = hub(t);
|
||||
const r = unit(h, { flags: [...libraryFlags(h), "--send"] });
|
||||
@@ -736,8 +801,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 +837,50 @@ 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 when its selector.sh is missing, before any check", (t) => {
|
||||
const x = host(t), lone = join(x.root, "lone", "scripts", "case2-precheck.sh");
|
||||
mkdirSync(dirname(lone), { recursive: true });
|
||||
cpSync(PRECHECK, lone);
|
||||
const r = spawnSync(lone, ["--org", "acme"], { encoding: "utf8", env: { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home } });
|
||||
assert.equal(r.status, 4, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.ok(r.stderr.endsWith(`case2-precheck: cannot read ${join(x.root, "lone", "scripts", "selector.sh")}\n`), r.stderr);
|
||||
});
|
||||
|
||||
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/);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// `mosaic dev fixture`: refusals, the state file's checks, and one real
|
||||
// init/status/stop of the loopback endpoint in a scratch HOME.
|
||||
// `mosaic dev fixture`: refusals, the state file's checks, one real
|
||||
// init/status/stop of the loopback endpoint in a scratch HOME, and the
|
||||
// replacement of a record whose endpoint has ended.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { devCommand, fixtureFile, fixtureLaunchEnv, fixtureLauncherOptions, isEndpoint, orgUsesFixture, readFixture } from '../src/dev-fixture.mjs';
|
||||
import { devCommand, fixtureFile, fixtureLaunchEnv, fixtureLauncherOptions, isEndpoint, orgUsesFixture, readFixture, takeStale } from '../src/dev-fixture.mjs';
|
||||
import { FIXTURE_ENTRY, FIXTURE_KEY } from '../../plugins/model-provider/fixture/index.mjs';
|
||||
import { TTL_DEFAULT_S, TTL_MAX_S } from '../../plugins/model-provider/fixture/endpoint.mjs';
|
||||
import { io } from './helpers.mjs';
|
||||
|
||||
// devOwnerInstance reads the instance from MOSAIC_CONFIG under $HOME/.config.
|
||||
@@ -36,6 +38,8 @@ test('init refuses before it starts anything', async () => {
|
||||
await refused(INIT, envFor('fxtest', { ANTHROPIC_API_KEY: 'sk-ant-value-never-printed' }), system, 'fixture-credential-present');
|
||||
await assert.rejects(devCommand(INIT, io(envFor('fxtest', { ZAI_API_KEY: 'zai-value-never-printed' })), { system }), e => /ZAI_API_KEY/.test(e.message) && !/never-printed/.test(e.message));
|
||||
await refused(INIT, envFor(), { ...system, proxy: { url: 'http://127.0.0.1:1' } }, 'fixture-proxy-configured');
|
||||
for (const ttl of ['-1', '1.5', '59', String(TTL_MAX_S + 1), '060', '1e3', 'soon', ''])
|
||||
await assert.rejects(devCommand([...INIT, '--ttl', ttl], io(envFor()), { system }), e => e.exitCode === 4 && /--ttl takes 0 \(no expiry\) or whole seconds/.test(e.message), `--ttl ${ttl}`);
|
||||
assert.equal(existsSync(join(system.dataRoot, 'dev-fixture')), false);
|
||||
});
|
||||
|
||||
@@ -98,6 +102,7 @@ test('a real init, status and stop on loopback', async t => {
|
||||
assert.equal(receipt.receipt, 'dev-fixture-init');
|
||||
assert.match(receipt.endpoint, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
|
||||
assert.deepEqual([receipt.loopback, receipt.external, receipt.upstream, receipt.instance, receipt.principal, receipt.modelKey], [true, false, 'none', 'fxtest', 'local-dev:local-dev-owner', 'fixture-deterministic']);
|
||||
assert.deepEqual([receipt.ttlSeconds, receipt.replaced], [TTL_DEFAULT_S, null]);
|
||||
assert.equal(isEndpoint(receipt.pid), true);
|
||||
assert.equal(JSON.parse(readFileSync(fixtureFile(system.dataRoot), 'utf8')).pid, receipt.pid);
|
||||
assert.doesNotMatch(readFileSync(fixtureFile(system.dataRoot), 'utf8'), new RegExp(FIXTURE_KEY));
|
||||
@@ -106,6 +111,8 @@ test('a real init, status and stop on loopback', async t => {
|
||||
await devCommand(['fixture', 'status', '--json'], s, { system });
|
||||
const status = JSON.parse(s.out.text);
|
||||
assert.deepEqual([status.ready, status.alive, status.external, status.upstream, status.proxyConfigured, status.health.upstream], [true, true, false, 'none', false, 'none']);
|
||||
// The default lifetime reaches the endpoint process.
|
||||
assert.deepEqual([status.ttlSeconds, status.health.ttlSeconds], [TTL_DEFAULT_S, TTL_DEFAULT_S]);
|
||||
const p = io(env);
|
||||
await devCommand(['fixture', 'status', '--json'], p, { system: { ...system, proxy: {} } });
|
||||
assert.equal(JSON.parse(p.out.text).ready, false);
|
||||
@@ -119,3 +126,78 @@ test('a real init, status and stop on loopback', async t => {
|
||||
assert.equal(readFixture(system.dataRoot), null);
|
||||
await refused(['fixture', 'status'], env, system, undefined, 2);
|
||||
});
|
||||
|
||||
// P19 #882. The endpoint's lifetime ends through the same stop as SIGTERM, which leaves the
|
||||
// record behind; init used to refuse fixture-exists on it until `dev fixture stop`. The
|
||||
// lifetime's timer is tested in packages/plugins/tests/model-provider-fixture.test.mjs.
|
||||
test('init replaces the record of an endpoint that has ended, and --ttl reaches the endpoint', async t => {
|
||||
const system = { dataRoot: dataRoot() };
|
||||
const env = envFor();
|
||||
const pids = [];
|
||||
t.after(() => { for (const pid of pids) if (isEndpoint(pid)) process.kill(pid, 'SIGKILL'); });
|
||||
const init = async (...extra) => {
|
||||
const x = io(env); await devCommand([...INIT, '--json', ...extra], x, { system });
|
||||
const receipt = JSON.parse(x.out.text); pids.push(receipt.pid); return receipt;
|
||||
};
|
||||
const status = async () => { const x = io(env); await devCommand(['fixture', 'status', '--json'], x, { system }); return JSON.parse(x.out.text); };
|
||||
|
||||
const first = await init('--ttl', '0');
|
||||
assert.deepEqual([first.ttlSeconds, first.expiresAt, first.replaced], [0, null, null]);
|
||||
let s = await status();
|
||||
assert.deepEqual([s.ready, s.health.ttlSeconds, s.health.expiresAt], [true, 0, null]);
|
||||
|
||||
process.kill(first.pid, 'SIGTERM');
|
||||
for (let i = 0; i < 50 && isEndpoint(first.pid); i++) await new Promise(r => setTimeout(r, 100));
|
||||
assert.equal(isEndpoint(first.pid), false);
|
||||
assert.equal(readFixture(system.dataRoot).pid, first.pid, 'an ended endpoint leaves its record');
|
||||
s = await status();
|
||||
assert.deepEqual([s.ready, s.alive], [false, false]);
|
||||
|
||||
const second = await init('--ttl', '120');
|
||||
assert.notEqual(second.pid, first.pid);
|
||||
assert.deepEqual(second.replaced, { pid: first.pid, startedAt: first.startedAt });
|
||||
assert.equal(readFixture(system.dataRoot).pid, second.pid);
|
||||
s = await status();
|
||||
assert.deepEqual([s.ready, s.ttlSeconds, s.health.ttlSeconds], [true, 120, 120]);
|
||||
assert.ok(Math.abs(Date.parse(s.health.expiresAt) - Date.parse(second.expiresAt)) < 10000, `${s.health.expiresAt} vs ${second.expiresAt}`);
|
||||
assert.equal(Date.parse(second.expiresAt) - Date.parse(second.startedAt), 120000);
|
||||
|
||||
// A live endpoint's record still refuses.
|
||||
await refused(INIT, env, system, 'fixture-exists');
|
||||
const stopped = io(env);
|
||||
await devCommand(['fixture', 'stop', '--json'], stopped, { system });
|
||||
assert.equal(JSON.parse(stopped.out.text).signaled, true);
|
||||
|
||||
// A record whose pid now names another process (this test runner) is stale too.
|
||||
const reused = dataRoot();
|
||||
writeState(reused, { ...RECORD, pid: process.pid });
|
||||
const x = io(env); await devCommand([...INIT, '--json'], x, { system: { dataRoot: reused } });
|
||||
const third = JSON.parse(x.out.text); pids.push(third.pid);
|
||||
assert.deepEqual(third.replaced, { pid: process.pid, startedAt: null });
|
||||
await devCommand(['fixture', 'stop'], io(env), { system: { dataRoot: reused } });
|
||||
assert.equal(isEndpoint(third.pid), false);
|
||||
});
|
||||
|
||||
// Review P2 (org-test-health): two inits both observe the same stale record. The first replaces
|
||||
// it with its live endpoint's record; the second, resuming, must not delete that new record.
|
||||
test('a stale record is removed only if it is still the one observed; a newer record stays', () => {
|
||||
const observed = { ...RECORD, startedAt: '2026-11-08T00:00:00.000Z' };
|
||||
const root = dataRoot();
|
||||
writeState(root, observed);
|
||||
takeStale(root, observed, 'fxtest');
|
||||
assert.equal(existsSync(fixtureFile(root)), false, 'the observed stale record goes');
|
||||
assert.deepEqual(readdirSync(join(root, 'dev-fixture')), [], 'nothing is left aside');
|
||||
|
||||
const raced = dataRoot();
|
||||
const newer = { ...RECORD, pid: 4242, startedAt: '2026-11-08T00:00:05.000Z' };
|
||||
writeState(raced, newer);
|
||||
const before = readFileSync(fixtureFile(raced), 'utf8');
|
||||
assert.throws(() => takeStale(raced, observed, 'fxtest'), e => e.code === 'fixture-exists');
|
||||
assert.equal(readFileSync(fixtureFile(raced), 'utf8'), before, 'the other init\'s record is back, unchanged');
|
||||
assert.deepEqual(readdirSync(join(raced, 'dev-fixture')), ['fixture.json'], 'nothing is left aside');
|
||||
|
||||
const gone = dataRoot();
|
||||
mkdirSync(join(gone, 'dev-fixture'), { mode: 0o700 });
|
||||
takeStale(gone, observed, 'fxtest');
|
||||
assert.equal(existsSync(fixtureFile(gone)), false, 'a record already taken by the other init is not an error');
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { CliError } from "../src/errors.mjs";
|
||||
import { busExit } from "../src/transport.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { main, run } from "../src/cli.mjs";
|
||||
@@ -358,3 +360,33 @@ test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call",
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('v1 business instances, instance and veto retain broker proof refusals and leave files untouched', async t => {
|
||||
const s = await setup(t);
|
||||
const before = readFileSync(s.file, 'utf8');
|
||||
const reader = s.bus.broker.bindReader({ business: 'acme' });
|
||||
const calls = [];
|
||||
const deps = { system: { dataRoot: s.f.dataRoot }, transport: business => async (verb, args) => {
|
||||
calls.push({ business, verb, args });
|
||||
try { return structuredClone(s.bus.broker.request(reader, { verb, args })); }
|
||||
catch (error) { const e = new CliError(error.code, busExit(error.code)); e.code = error.code; throw e; }
|
||||
} };
|
||||
const cases = [
|
||||
[['instances', 'acme', '--json'], 'instances', {}],
|
||||
[['instance', 'set', 'acme', 'coder', '--launch', '--revision', 'a'.repeat(64)], 'instances.set', { revision: 'a'.repeat(64), instance: 'coder', op: 'change', launch: true }],
|
||||
[['veto', 'acme', 'keep', 'coder', '--revision', 'a'.repeat(64)], 'veto.set', { revision: 'a'.repeat(64), op: 'keep', name: 'coder' }],
|
||||
];
|
||||
for (const [args, verb, request] of cases) {
|
||||
const x = io(s.f.env);
|
||||
assert.equal(await run(['business', ...args], x, deps), 3, args.join(' '));
|
||||
assert.equal(x.out.text, '');
|
||||
assert.match(x.err.text, /human-required/);
|
||||
assert.deepEqual(calls.at(-1), { business: 'acme', verb, args: request });
|
||||
const count = calls.length;
|
||||
const agent = io({ ...s.f.env, CODEX_THREAD_ID: 'fixture-agent' });
|
||||
assert.equal(await run(['business', ...args], agent, deps), 3);
|
||||
assert.match(agent.err.text, /human shell/);
|
||||
assert.equal(calls.length, count);
|
||||
}
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before);
|
||||
});
|
||||
@@ -29,6 +29,9 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
|
||||
const url = new URL(result.url);
|
||||
assert.equal(url.hostname, '127.0.0.1');
|
||||
assert.ok(Number.isSafeInteger(result.pid));
|
||||
// A selector producer (rename r2): the Console child gets the active organization as --business.
|
||||
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
|
||||
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
|
||||
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
|
||||
assert.equal(status, 200);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -22,7 +22,8 @@ export function setup(t, realPin=false){
|
||||
execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
|
||||
});
|
||||
mkdirSync(repo);mkdirSync(bin);
|
||||
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
|
||||
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
|
||||
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
||||
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
|
||||
const pkg={name:'copy-fixture',version:'1.0.0'};
|
||||
@@ -361,6 +362,61 @@ test('row 232: check --units external reads the system units, the default reads
|
||||
}
|
||||
});
|
||||
|
||||
// Rename r2 "Selector scopes and parser matrix": check takes --org as --business, each spelling once, both only
|
||||
// with one value; a refused selector reads no unit, and --org is refused outside check as --business is.
|
||||
test('rename r2: check takes --org as --business, once per spelling, and refuses before any unit read',async t=>{
|
||||
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
||||
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create'])).status,0);
|
||||
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--copies',s.copies,'--bin',s.bin];
|
||||
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
|
||||
const shows=b=>[`--user show mosaic-bus@${b}.service -p LoadState -p WorkingDirectory -p ExecStart`,'--user show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart'];
|
||||
for(const sel of [['--business','demo'],['--org','demo'],['--business','demo','--org','demo'],['--org','demo','--business','demo'],['--org','demo','--units','user']]){
|
||||
const r=await s.run('stack-copy.sh',[...base,...sel]);
|
||||
assert.equal(r.status,0,sel.join(' ')+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
|
||||
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'),sel.join(' '));rmSync(argv);
|
||||
}
|
||||
// The selector may come before the other options too.
|
||||
let r=await s.run('stack-copy.sh',['check','--org','demo','--copies',s.copies,'--bin',s.bin]);assert.equal(r.status,0,r.stderr);
|
||||
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'));rmSync(argv);
|
||||
for(const [sel,status,message] of [
|
||||
[['--business','demo','--org','beta'],4,'--business demo and --org beta conflict'],
|
||||
[['--org','beta','--business','demo'],4,'--org beta and --business demo conflict'],
|
||||
[['--business','demo','--business','demo'],4,'--business given twice'],
|
||||
[['--org','demo','--org','demo'],4,'--org given twice'],
|
||||
[['--org','demo','--business','demo','--org','demo'],4,'--org given twice'],
|
||||
[['--org'],4,'--org needs one value'],
|
||||
[['--business'],4,'--business needs one value'],
|
||||
[['--org',''],4,'--org needs one value'],
|
||||
[['--org','--units','user'],4,'--org needs one value, not --units'],
|
||||
[['--business','-x'],4,'--business needs one value, not -x'],
|
||||
[['--org','Demo'],1,'--org Demo is not valid'],
|
||||
[['--business','demo_x'],1,'--business demo_x is not valid'],
|
||||
[['--org','-demo'],4,'--org needs one value, not -demo'],
|
||||
]){
|
||||
r=await s.run('stack-copy.sh',[...base,...sel]);
|
||||
assert.equal(r.status,status,sel.join(' '));assert.equal(r.stderr,`stack-copy: ${message}\n`,sel.join(' '));assert.equal(r.stdout,'',sel.join(' '));
|
||||
assert.ok(!existsSync(argv),`${sel.join(' ')} reads no unit`);
|
||||
}
|
||||
// Positional text after a selector value stays a usage error; --org outside check is one too.
|
||||
for(const args of [[...base,'--org','demo','beta'],['copy',s.id,'--copies',s.copies,'--org','demo'],['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--org','demo'],['copy',s.id,'--business','demo']]){
|
||||
r=await s.run('stack-copy.sh',args);assert.equal(r.status,4,args.join(' '));assert.match(r.stderr,/^usage: stack-copy\.sh /,args.join(' '));
|
||||
assert.ok(!existsSync(argv),args.join(' '));
|
||||
}
|
||||
});
|
||||
test('rename r2: check --instance takes --org for its business and still needs one selector',async t=>{
|
||||
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
||||
assert.equal((await s.run('bootstrap.sh',['--instance','alpha'])).status,0);
|
||||
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin])).status,0);
|
||||
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--instance','alpha','--copies',s.copies,'--bin',s.bin];
|
||||
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
|
||||
for(const sel of [['--org','demo'],['--business','demo','--org','demo']]){
|
||||
const r=await s.run('stack-copy.sh',[...base,...sel]);assert.equal(r.status,0,sel.join(' ')+r.stderr);
|
||||
assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
|
||||
assert.equal(readFileSync(argv,'utf8').split('\n')[0],'--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',sel.join(' '));rmSync(argv);
|
||||
}
|
||||
const r=await s.run('stack-copy.sh',base);assert.equal(r.status,4);assert.match(r.stderr,/^usage: stack-copy\.sh /);assert.ok(!existsSync(argv));
|
||||
});
|
||||
|
||||
test('Vikunja 614: bundle and checkout copy leave an unreadable caller directory before find',async t=>{
|
||||
const s=setup(t),cwd=join(s.root,'unreadable');mkdirSync(cwd);
|
||||
const bundle=join(s.root,'release.bundle');git(s.repo,'bundle','create','-q',bundle,'refactor');
|
||||
|
||||
@@ -272,7 +272,8 @@ after(()=>{execFileSync('chmod',['-R','u+w',base]);rmSync(base,{recursive:true,f
|
||||
export const repo=join(base,'repo'),COPIES=join(base,'copies'),SBIN=join(base,'sbin');
|
||||
mkdirSync(repo);mkdirSync(COPIES);mkdirSync(SBIN);
|
||||
writeFileSync(join(base,'package.json'),'{"type":"commonjs"}\n');
|
||||
export const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
|
||||
export const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
|
||||
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
||||
export const pkg={name:'copy-fixture',version:'1.0.0'};
|
||||
writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
|
||||
|
||||
@@ -2069,7 +2069,7 @@ export class Store {
|
||||
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
|
||||
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
|
||||
const d=join(RCOPIES,id);
|
||||
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/org-selector.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
// Row 333 R2 (#1831): what the business loader imports from outside its directory, model-quota.mjs included.
|
||||
for(const p of businessImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p));
|
||||
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {execFileSync,spawn,spawnSync} from 'node:child_process';
|
||||
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync} from 'node:fs';
|
||||
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync,realpathSync} from 'node:fs';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {join,dirname} from 'node:path';
|
||||
import {once} from 'node:events';
|
||||
@@ -47,7 +47,7 @@ function fakeSudo(K){
|
||||
|
||||
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
|
||||
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
|
||||
// as case2-unit.sh does. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
|
||||
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
|
||||
// probe.sh, a second driver, and records its exit and stderr.
|
||||
function fakeRender(K){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
|
||||
@@ -64,7 +64,7 @@ function fakeRender(K){
|
||||
const sh=(n,...x)=>cp.execFileSync('bash',[path.join(get('--home'),'src','mosaic-stack','scripts',n),...x],{stdio:['ignore','ignore','inherit']});
|
||||
sh('bus-service.sh','install','--copy',copy,'--copies',copies,...(pre.includes('--pm')?['--pm']:[]),'--claude',get('--claude'));
|
||||
sh('webui-service.sh','install','--copy',copy,'--copies',copies,...serve);
|
||||
const names=[`mosaic-bus@${get('--business')}.service`,'mosaic-webui.service'];
|
||||
const names=[`mosaic-bus@${get(pre.includes('--business')?'--business':'--org')}.service`,'mosaic-webui.service'];
|
||||
if(pre.includes('--proxy')){
|
||||
fs.writeFileSync(path.join(process.env.XDG_CONFIG_HOME,'systemd','user','mosaic-proxy.service'),`[Service]\nWorkingDirectory=${copies}/${copy}\nExecStart=${get('--node')} ${copies}/${copy}/packages/model-proxy/src/run.mjs\n`);
|
||||
names.unshift('mosaic-proxy.service');
|
||||
@@ -592,6 +592,108 @@ test('#385 R2-F1 controls: library args without --send and a factory args file p
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
|
||||
});
|
||||
|
||||
// Rename r2, "Selector scopes and parser matrix": --org is the same option as --business before -- (the
|
||||
// unit's flags) and after it (the serve flags). selArgs moves the business into each scope as given: the
|
||||
// unit's selectors first, the serve flags' last, the other lines as useed wrote them.
|
||||
const selArgs=(e,outer,serve)=>{
|
||||
const l=e.argsText.trimEnd().split('\n'),cut=l.indexOf('--'),drop=(a)=>{a.splice(a.indexOf('--business'),2);return a};
|
||||
return [...outer,...drop(l.slice(0,cut)),'--',...drop(l.slice(cut+1)),...serve].join('\n')+'\n';
|
||||
};
|
||||
test('rename r2: --org or --business names the business before and after --, read_args passes either spelling or both, matched, in either order',async(t)=>{
|
||||
const e=await useed(t);
|
||||
// A bundle that does not exist stops the run at its first step after read_args, with nothing stopped.
|
||||
const gone=join(e.root,'no.bundle');
|
||||
for(const [outer,serve] of [
|
||||
[['--org','acme'],['--org','acme']],
|
||||
[['--business','acme'],['--org','acme']],
|
||||
[['--org','acme'],['--business','acme']],
|
||||
[['--business','acme','--org','acme'],['--org','acme','--business','acme']],
|
||||
[['--org','acme','--business','acme'],['--business','acme','--org','acme']],
|
||||
]){
|
||||
const why=`${outer.join(' ')} -- ${serve.join(' ')}`,text=selArgs(e,outer,serve);
|
||||
writeFileSync(e.args,text);chmodSync(e.args,0o640);
|
||||
const n=existsSync(e.slog)?slog(e).length:0;
|
||||
const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',gone,'--sha',C1,...TO);assert.equal(r.code,3,`${why}: ${r.all}`);
|
||||
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','fetch','result'],why);
|
||||
assert.match(slog(e).at(-1),/^result exit=3 fetch-failed /,why);
|
||||
assert.equal(readFileSync(e.args,'utf8'),text,why);
|
||||
}
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
|
||||
});
|
||||
|
||||
test('rename r2: an upgrade from an args file that spells both selectors --org keeps every line but --copy\'s value, and the render gets them as written',async(t)=>{
|
||||
const e=await useed(t);
|
||||
e.argsText=selArgs(e,['--org','acme'],['--org','acme']);writeFileSync(e.args,e.argsText);
|
||||
arm(e,'confirm.sh',`cp ${e.args} ${join(e.sd,'args-at-confirm')}\n`);
|
||||
const r=await upgrade(e,C1);assert.equal(r.code,0,r.all);
|
||||
assert.equal(r.out.split('\n').at(-2),`stack-upgrade: upgrade ok: ${C1} is live for acme`);
|
||||
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']);
|
||||
assert.equal(readFileSync(join(e.sd,'args-at-confirm'),'utf8'),e.argsText);
|
||||
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
|
||||
assert.equal(readFileSync(e.args,'utf8'),after);
|
||||
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
|
||||
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
|
||||
// The stack's user's steps get the business as a word, never a spelling.
|
||||
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
|
||||
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
|
||||
assert.deepEqual(live(e),['active','active',C1,C1]);
|
||||
});
|
||||
|
||||
test('rename r2: a bad selector in either scope, or scopes that name different businesses, refuse args-unreadable before anything stops',async(t)=>{
|
||||
const e=await useed(t),f=e.args;
|
||||
const l=e.argsText.trimEnd().split('\n'),pre=l.slice(0,l.indexOf('--')).filter((x,i,a)=>x!=='--business'&&a[i-1]!=='--business');
|
||||
const before=(m)=>`${f} before --: ${m}`,after=(m)=>`${f} after --: ${m}`;
|
||||
const cases=[
|
||||
['unit: two spellings, two businesses',selArgs(e,['--business','acme','--org','beta'],['--business','acme']),before('--business acme and --org beta conflict')],
|
||||
['unit: two businesses, the other order',selArgs(e,['--org','acme','--business','beta'],['--business','acme']),before('--org acme and --business beta conflict')],
|
||||
['unit: --org twice',selArgs(e,['--org','acme','--org','acme'],['--business','acme']),before('--org given twice')],
|
||||
['unit: --business twice',selArgs(e,['--business','acme','--business','acme'],['--business','acme']),before('--business given twice')],
|
||||
['unit: --org at the end',[...pre,'--org'].join('\n')+'\n',before('--org needs one value')],
|
||||
['unit: --org with an empty line',selArgs(e,['--org',''],['--business','acme']),before('--org needs one value')],
|
||||
['unit: --org before a flag',selArgs(e,['--org'],['--business','acme']),before('--org needs one value, not --user')],
|
||||
['unit: --business before --',[...pre,'--business','--','--port',e.port,'--business','acme'].join('\n')+'\n',before('--business needs one value, not --')],
|
||||
['unit: an invalid business',selArgs(e,['--org','Acme'],['--org','Acme']),before('--org Acme is not valid')],
|
||||
['unit: --org as --home\'s value is no selector',selArgs(e,['--business','acme'],['--business','acme']).replace(`--home\n${e.env.HOME}\n`,'--home\n--org\n'),`${f} lacks a plain --user, --home, --business, --node or --copy`],
|
||||
['serve: two spellings, two businesses',selArgs(e,['--business','acme'],['--business','acme','--org','beta']),after('--business acme and --org beta conflict')],
|
||||
['serve: two businesses, the other order',selArgs(e,['--business','acme'],['--org','beta','--business','acme']),after('--org beta and --business acme conflict')],
|
||||
['serve: --org twice',selArgs(e,['--business','acme'],['--org','acme','--org','acme']),after('--org given twice')],
|
||||
['serve: --business twice',selArgs(e,['--org','acme'],['--business','acme','--business','acme']),after('--business given twice')],
|
||||
['serve: --org at the end',selArgs(e,['--business','acme'],['--org']),after('--org needs one value')],
|
||||
['serve: --org before a flag',selArgs(e,['--business','acme'],['--org','--sso']),after('--org needs one value, not --sso')],
|
||||
['serve: an invalid business',selArgs(e,['--business','acme'],['--org','acme_x']),after('--org acme_x is not valid')],
|
||||
['serve: another business',selArgs(e,['--business','acme'],['--org','beta']),`${f} names business acme before -- and beta after it`],
|
||||
['serve: another business, unit spelled --org',selArgs(e,['--org','acme'],['--business','beta']),`${f} names business acme before -- and beta after it`],
|
||||
['serve: no selector',selArgs(e,['--business','acme'],[]),`${f} needs --business or --org after --, as case2-unit.sh does`],
|
||||
['serve: --business as --allow-user\'s value is no selector',selArgs(e,['--business','acme'],['--allow-user','--business']),`${f} needs --business or --org after --, as case2-unit.sh does`],
|
||||
['serve: no -- at all',[...pre,'--org','acme'].join('\n')+'\n',`${f} needs --business or --org after --, as case2-unit.sh does`],
|
||||
['serve: a flag case2-unit.sh does not take',selArgs(e,['--business','acme'],['--business','acme','--tester']),`${f} has --tester after --, which case2-unit.sh does not take`],
|
||||
];
|
||||
for(const [why,text,msg] of cases){
|
||||
writeFileSync(e.args,text);chmodSync(e.args,0o640);
|
||||
const n=existsSync(e.slog)?slog(e).length:0;
|
||||
const r=await upgrade(e,C1);assert.equal(r.code,3,`${why}: ${r.all}`);
|
||||
assert.equal(r.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${msg} (nothing was stopped)`,why);
|
||||
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','result'],why);
|
||||
assert.equal(readFileSync(e.args,'utf8'),text,why);assert.deepEqual(readdirSync(e.etc),['dev.args'],why);
|
||||
}
|
||||
// A rollback reads the same file the same way.
|
||||
writeFileSync(e.args,cases[0][1]);
|
||||
const rb=await drive(e,B,'rollback','--instance','dev','--to',B,...TO);assert.equal(rb.code,3,rb.all);
|
||||
assert.equal(rb.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${cases[0][2]} (nothing was stopped)`);
|
||||
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'render.log')));
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
|
||||
});
|
||||
|
||||
test('rename r2: a checkout without scripts/selector.sh refuses checkout-unreadable before reading the args file',async(t)=>{
|
||||
const e=await useed(t),d=join(realpathSync(e.root),'lone');
|
||||
mkdirSync(join(d,'scripts'),{recursive:true});copyFileSync(join(checkout(C1),'scripts','stack-upgrade.sh'),join(d,'scripts','stack-upgrade.sh'));
|
||||
const r=spawnSync('bash',[join(d,'scripts','stack-upgrade.sh'),'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1,...TO],{env:e.denv,encoding:'utf8'});
|
||||
assert.equal(r.status,3,r.stdout+r.stderr);
|
||||
assert.equal(r.stderr.split('\n').at(-2),`stack-upgrade: checkout-unreadable: cannot read ${d}/scripts/selector.sh (nothing was stopped)`);
|
||||
assert.deepEqual(slog(e).map((x)=>x.split(' ')[0]),['start','result']);
|
||||
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));argsKept(e);
|
||||
});
|
||||
|
||||
// R3-F1 (#385, PR 1836): a library host rendered by base case2-unit.sh has --send in its installed Console
|
||||
// unit's ExecStart, not only in its args file. With the args line removed, A2 would stop both units and A4
|
||||
// and render-old would both refuse the installed unit (check_unit's ExecStart and the full-text match), so
|
||||
|
||||
@@ -2,20 +2,33 @@
|
||||
// messages API with FIXTURE_WORD for FIXTURE_MODEL, only on 127.0.0.1, only
|
||||
// with the published fixture key, and forwards nothing anywhere.
|
||||
// Run as a process: prints one JSON line {"ready":true,"port":N} and serves
|
||||
// until SIGTERM/SIGINT or its lifetime ends.
|
||||
// until SIGTERM/SIGINT or its lifetime (MOSAIC_FIXTURE_TTL_S) ends.
|
||||
import { createServer } from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FIXTURE_KEY, FIXTURE_MODEL, FIXTURE_WORD } from './index.mjs';
|
||||
|
||||
export const HOST = '127.0.0.1';
|
||||
const BODY_MAX = 1024 * 1024;
|
||||
export const TTL_DEFAULT_S = 14400;
|
||||
export const TTL_MIN_S = 60;
|
||||
// The longest delay a Node timer holds; a longer one fires after 1 ms.
|
||||
export const TTL_MAX_S = 2147483;
|
||||
|
||||
export function createEndpoint({ log = () => {} } = {}) {
|
||||
// Seconds the process lives, from MOSAIC_FIXTURE_TTL_S. Unset, empty or not a number is the
|
||||
// default; 0 is no expiry (`mosaic dev fixture init --ttl 0`, a named dev instance only);
|
||||
// anything else is held to [TTL_MIN_S, TTL_MAX_S].
|
||||
export function lifetimeSeconds(raw) {
|
||||
const n = raw === undefined || raw === '' ? TTL_DEFAULT_S : Number(raw);
|
||||
if (!Number.isFinite(n)) return TTL_DEFAULT_S;
|
||||
return n === 0 ? 0 : Math.min(TTL_MAX_S, Math.max(TTL_MIN_S, n));
|
||||
}
|
||||
|
||||
export function createEndpoint({ log = () => {}, ttlSeconds = null, expiresAt = null } = {}) {
|
||||
const counts = { messages: 0, refused: 0 };
|
||||
const server = createServer((req, res) => {
|
||||
const reply = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
|
||||
const error = (status, type, message) => { counts.refused++; log(`${req.method} ${req.url} ${status} ${type}`); reply(status, { type: 'error', error: { type, message } }); };
|
||||
if (req.method === 'GET' && req.url === '/health') return reply(200, { ok: true, model: FIXTURE_MODEL, upstream: 'none', counts });
|
||||
if (req.method === 'GET' && req.url === '/health') return reply(200, { ok: true, model: FIXTURE_MODEL, upstream: 'none', counts, ttlSeconds, expiresAt });
|
||||
if (req.method !== 'POST' || !/^\/v1\/messages(\?|$)/.test(req.url ?? '')) return error(404, 'not_found_error', 'fixture serves POST /v1/messages only');
|
||||
if (req.headers['x-api-key'] !== FIXTURE_KEY) return error(401, 'authentication_error', 'fixture accepts only the fixture key');
|
||||
let size = 0; const chunks = [];
|
||||
@@ -50,14 +63,24 @@ export function createEndpoint({ log = () => {} } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ttl = Number(process.env.MOSAIC_FIXTURE_TTL_S ?? 14400);
|
||||
const endpoint = createEndpoint({ log: line => process.stderr.write(`${new Date().toISOString()} ${line}\n`) });
|
||||
const { port } = await endpoint.listen(0);
|
||||
process.stdout.write(`${JSON.stringify({ ready: true, port, pid: process.pid })}\n`);
|
||||
const stop = async () => { await endpoint.close(); process.exit(0); };
|
||||
process.on('SIGTERM', stop); process.on('SIGINT', stop);
|
||||
// Listens on loopback and runs `expire` once ttlSeconds pass; ttlSeconds 0 never expires.
|
||||
export async function serve({ ttlSeconds, log, expire }) {
|
||||
const expiresAt = ttlSeconds ? new Date(Date.now() + ttlSeconds * 1000).toISOString() : null;
|
||||
const endpoint = createEndpoint({ log, ttlSeconds, expiresAt });
|
||||
const address = await endpoint.listen(0);
|
||||
// A bounded lifetime, so an endpoint a crashed caller left behind still ends.
|
||||
setTimeout(stop, Math.max(60, Number.isFinite(ttl) ? ttl : 14400) * 1000).unref();
|
||||
const timer = ttlSeconds ? setTimeout(expire, ttlSeconds * 1000) : null;
|
||||
timer?.unref();
|
||||
return { endpoint, ...address, expiresAt, timer };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let endpoint;
|
||||
// Expiry and SIGTERM end the process the same way, leaving any record its caller wrote.
|
||||
const stop = async () => { await endpoint?.close(); process.exit(0); };
|
||||
const served = await serve({ ttlSeconds: lifetimeSeconds(process.env.MOSAIC_FIXTURE_TTL_S), log: line => process.stderr.write(`${new Date().toISOString()} ${line}\n`), expire: stop });
|
||||
endpoint = served.endpoint;
|
||||
process.stdout.write(`${JSON.stringify({ ready: true, port: served.port, pid: process.pid })}\n`);
|
||||
process.on('SIGTERM', stop); process.on('SIGINT', stop);
|
||||
}
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main().catch(e => { process.stderr.write(`fixture endpoint: ${e.message}\n`); process.exit(1); });
|
||||
@@ -4,7 +4,7 @@ import { request } from 'node:http';
|
||||
import { createLoader, hasModule } from '../src/loader.mjs';
|
||||
import { selectModelProvider } from '../src/model-provider-selection.mjs';
|
||||
import * as fixture from '../model-provider/fixture/index.mjs';
|
||||
import { createEndpoint, HOST } from '../model-provider/fixture/endpoint.mjs';
|
||||
import { createEndpoint, HOST, lifetimeSeconds, serve, TTL_DEFAULT_S, TTL_MAX_S, TTL_MIN_S } from '../model-provider/fixture/endpoint.mjs';
|
||||
|
||||
const { FIXTURE_ENTRY, FIXTURE_KEY, FIXTURE_MODEL, FIXTURE_WORD } = fixture;
|
||||
|
||||
@@ -82,3 +82,37 @@ test('the endpoint binds loopback, answers the fixed word and refuses a wrong ke
|
||||
assert.equal((await call(baseUrl, { method: 'GET' })).status, 404);
|
||||
assert.deepEqual(ep.counts, { messages: 2, refused: 4 });
|
||||
});
|
||||
|
||||
// P19 #882: the lifetime an endpoint process takes from MOSAIC_FIXTURE_TTL_S.
|
||||
test('the endpoint lifetime: a default, 0 for no expiry, and bounds a Node timer can hold', () => {
|
||||
for (const raw of [undefined, '', 'soon']) assert.equal(lifetimeSeconds(raw), TTL_DEFAULT_S, String(raw));
|
||||
assert.equal(TTL_DEFAULT_S, 14400);
|
||||
assert.equal(lifetimeSeconds('0'), 0);
|
||||
assert.equal(lifetimeSeconds('120'), 120);
|
||||
for (const raw of ['5', '-3']) assert.equal(lifetimeSeconds(raw), TTL_MIN_S, raw);
|
||||
// Past 2^31-1 ms a timer fires after 1 ms, so a longer lifetime is held to the longest one.
|
||||
for (const raw of [String(TTL_MAX_S + 1), '1e12']) assert.equal(lifetimeSeconds(raw), TTL_MAX_S, raw);
|
||||
assert.ok(TTL_MAX_S * 1000 <= 2 ** 31 - 1);
|
||||
});
|
||||
|
||||
test('serve expires the endpoint when its lifetime ends, and never with a lifetime of 0', async t => {
|
||||
const open = await serve({ ttlSeconds: 0, expire: () => assert.fail('a lifetime of 0 expired') });
|
||||
t.after(() => open.endpoint.close());
|
||||
assert.equal(open.timer, null);
|
||||
const health = JSON.parse((await call(open.baseUrl, { method: 'GET', path: '/health' })).text);
|
||||
assert.deepEqual([health.ok, health.ttlSeconds, health.expiresAt], [true, 0, null]);
|
||||
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let expired = 0;
|
||||
const before = Date.now();
|
||||
const timed = await serve({ ttlSeconds: 120, expire: () => { expired++; } });
|
||||
t.after(() => timed.endpoint.close());
|
||||
const at = Date.parse(timed.expiresAt) - before;
|
||||
assert.ok(at >= 120000 && at < 130000, timed.expiresAt);
|
||||
t.mock.timers.tick(119999);
|
||||
assert.equal(expired, 0);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(expired, 1);
|
||||
t.mock.timers.tick(TTL_MAX_S * 1000);
|
||||
assert.equal(expired, 1, 'a lifetime of 0 set no timer');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { initProject, readSpec } from '../src/index.mjs';
|
||||
|
||||
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'user.name=fixture', '-c', '[email protected]', '-C', repo, ...args], { encoding: 'utf8' });
|
||||
const bytes = (repo, rev, path) => git(repo, 'show', `${rev}:${path}`);
|
||||
|
||||
test('v1 metadata at each reachable committed revision is checked before reading', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'revision-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const business = { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } };
|
||||
const ctx = { business, project: 'stack', dataRoot: root };
|
||||
const { repo, commit: initial } = await initProject(ctx);
|
||||
const original = bytes(repo, initial, 'project.json');
|
||||
const meta = JSON.parse(original);
|
||||
assert.deepEqual({ ...meta, createdAt: '<time>' }, { layoutVersion: 1, business: 'acme', project: 'stack', name: 'stack', createdAt: '<time>' });
|
||||
assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/);
|
||||
assert.equal(bytes(repo, initial, 'templates/project.json'), readFileSync(new URL('../templates/project.json', import.meta.url), 'utf8'));
|
||||
const save = (metadata, content) => {
|
||||
writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n');
|
||||
writeFileSync(join(repo, 'spec/north-star.md'), content);
|
||||
git(repo, 'add', 'project.json', 'spec/north-star.md');
|
||||
git(repo, 'commit', '-m', 'fixture revision');
|
||||
return git(repo, 'rev-parse', 'HEAD').trim();
|
||||
};
|
||||
const valid = save({ ...meta, name: 'Second' }, '# Second\n');
|
||||
const badOrg = save({ ...meta, business: 'other' }, '# Wrong org\n');
|
||||
const badProject = save({ ...meta, project: 'other' }, '# Wrong project\n');
|
||||
const badMix = save({ ...meta, organization: 'acme' }, '# Mixed\n');
|
||||
const head = save(meta, '# Head\n');
|
||||
for (const rev of [initial, valid, head]) assert.equal((await readSpec({ ...ctx, commit: rev })).revision, rev);
|
||||
assert.equal((await readSpec({ ...ctx, commit: valid })).content, '# Second\n');
|
||||
for (const rev of [badOrg, badProject, badMix]) await assert.rejects(readSpec({ ...ctx, commit: rev }), { code: 'project-invalid' });
|
||||
writeFileSync(join(repo, 'project.json'), '{"business":"dirty"}');
|
||||
assert.equal((await readSpec({ ...ctx, commit: initial })).revision, initial);
|
||||
assert.equal(bytes(repo, initial, 'project.json'), original);
|
||||
});
|
||||
@@ -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 [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
@@ -31,8 +32,15 @@ try {
|
||||
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
||||
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
|
||||
const devNames = [];
|
||||
// --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.
|
||||
@@ -54,10 +62,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;
|
||||
@@ -72,6 +76,7 @@ try {
|
||||
} else if (flag === '--dev-allow-host') devNames.push(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 lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
|
||||
const instance = devOwner ? devOwnerInstance() : null;
|
||||
|
||||
@@ -103,11 +103,32 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
|
||||
const { Store } = await import('../../bus/src/store.mjs');
|
||||
const { Broker } = await import('../../bus/src/broker.mjs');
|
||||
const root=mkdtempSync(join(tmpdir(),'o-'));
|
||||
t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
// P19 #875: t.after hooks run in registration order, so removing root came first, both
|
||||
// `bus stop` calls then failed, and SIGTERM to the outer `bus start` reached neither the
|
||||
// --internal-host process nor its broker; they outlived the file. One hook now stops the
|
||||
// Console and both buses while root exists, fails if any recorded process survives, then
|
||||
// removes root. Each process is recorded with its start time so a reused pid is not it.
|
||||
const startOf=pid=>{ try { const raw=readFileSync(`/proc/${pid}/stat`,'utf8'), fields=raw.slice(raw.lastIndexOf(')')+2).split(' '); return fields[0]==='Z'||fields[0]==='X'?null:fields[19]; } catch { return null; } };
|
||||
const tracked=new Map();
|
||||
const track=pid=>{ const start=startOf(pid); if(!start||tracked.has(pid))return; tracked.set(pid,start); let kids=''; try { kids=readFileSync(`/proc/${pid}/task/${pid}/children`,'utf8'); } catch {} for(const kid of kids.split(' ').filter(Boolean))track(Number(kid)); };
|
||||
const survivors=()=>[...tracked].filter(([pid,start])=>startOf(pid)===start).map(([pid])=>pid);
|
||||
const hostOf=(out,org)=>JSON.parse(out.split('\n').find(line=>line.startsWith('{') && line.includes(`"org":"${org}"`))).pid;
|
||||
let env, pid;
|
||||
t.after(async()=>{
|
||||
try {
|
||||
if(pid){ try { process.kill(pid,'SIGTERM'); } catch {} }
|
||||
if(env) for(const org of ['acme','beta']) spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop',org],{env,encoding:'utf8'});
|
||||
for(let i=0;i<100 && survivors().length;i++) await new Promise(r=>setTimeout(r,100));
|
||||
const left=survivors();
|
||||
// Stop what survived so the failure is this assertion, not a leak into the next file.
|
||||
for(const p of left){ try { process.kill(p,'SIGKILL'); } catch {} }
|
||||
assert.deepEqual(left,[],`processes still running after teardown: ${left.join(' ')}`);
|
||||
} finally { rmSync(root,{recursive:true,force:true}); }
|
||||
});
|
||||
const f=fixture(root); const home=join(root,'home'), configDir=join(home,'.config','mosaic-test');
|
||||
mkdirSync(join(home,'.config'),{recursive:true}); renameSync(join(root,'config'),configDir); chmodSync(configDir, 0o700);
|
||||
writeFileSync(join(f.dataRoot,'instance.json'), JSON.stringify({instanceVersion:1,name:'test'}));
|
||||
const env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')};
|
||||
env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')};
|
||||
const ctx={configDir,dataRoot:f.dataRoot,instance:'test',principal:DEV_OWNER};
|
||||
const doc=structuredClone(f.doc); doc.human=DEV_OWNER.sub; doc.projects.stack.root='/__mosaic_org__/acme/projects/stack';
|
||||
rmSync(join(configDir,'businesses','acme.json')); chmodSync(join(configDir,'businesses'),0o700);
|
||||
@@ -128,13 +149,14 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
|
||||
betaStore.close();
|
||||
let betaOut=''; const betaBus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','beta','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
|
||||
betaBus.stdout.on('data',c=>betaOut+=c); betaBus.stderr.on('data',c=>betaOut+=c);
|
||||
t.after(()=>{ spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','beta'],{env,encoding:'utf8'}); if(betaBus.exitCode===null)betaBus.kill('SIGTERM'); });
|
||||
track(betaBus.pid);
|
||||
let bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
|
||||
let busOut=''; bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
|
||||
t.after(()=>{ spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); if(bus.exitCode===null)bus.kill('SIGTERM'); });
|
||||
track(bus.pid);
|
||||
const wait=async (fn) => { for(let i=0;i<300;i++){ const v=fn(); if(v)return v; await new Promise(r=>setTimeout(r,100)); } throw Error(`startup timeout: ${busOut}`); };
|
||||
await wait(()=>busOut.includes('\"org\":\"acme\"'));
|
||||
await wait(()=>betaOut.includes('\"org\":\"beta\"'));
|
||||
track(hostOf(busOut,'acme')); track(hostOf(betaOut,'beta'));
|
||||
const hostLog=()=>readFileSync(join(org.root,'bus-host','host.log'),'utf8');
|
||||
assert.match(hostLog(),/DEV LOCAL OWNER MODE: console owner local-dev accepted \(named instance test\)/);
|
||||
assert.match(hostLog(),/launches disabled/);
|
||||
@@ -143,8 +165,8 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
|
||||
const launch=spawnSync(process.execPath,['-e',`const {spawn}=require('node:child_process');const p=spawn(process.execPath,[require('node:path').resolve('packages/webui/src/cli.mjs'),'serve','--port','0','--board','http://127.0.0.1:9','--business','acme','--send','--dev-local-owner'],{detached:true,stdio:['ignore',1,2]});console.log('PID '+p.pid);p.unref();`],{env,stdio:['ignore',fd,fd]});
|
||||
closeSync(fd); assert.equal(launch.status,0);
|
||||
const output=()=>readFileSync(log,'utf8');
|
||||
const pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]);
|
||||
t.after(()=>{ try { process.kill(pid,'SIGTERM'); } catch {} });
|
||||
pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]);
|
||||
track(pid);
|
||||
const port=Number((await wait(()=>/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(output())))[1]);
|
||||
assert.match(output(),/DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner \(named instance test\)/);
|
||||
const post=async (path,body,extra={})=>{
|
||||
@@ -167,7 +189,9 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
|
||||
const stop = spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); assert.equal(stop.status,0,stop.stderr); await stopped;
|
||||
busOut=''; bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
|
||||
bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
|
||||
track(bus.pid);
|
||||
await wait(()=>busOut.includes('\"org\":\"acme\"'));
|
||||
track(hostOf(busOut,'acme'));
|
||||
const refused=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'});
|
||||
assert.equal(refused.status,403,JSON.stringify(refused)); assert.equal(refused.body.error,'human-required');
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
{
|
||||
"business": {
|
||||
"id": "acme",
|
||||
"human": "jason",
|
||||
"arbiters": {
|
||||
"delivery": "pm",
|
||||
"technical": "cto"
|
||||
},
|
||||
"projects": [
|
||||
"stack"
|
||||
],
|
||||
"actions": [
|
||||
"task.create",
|
||||
"project.bootstrap",
|
||||
"task.assign",
|
||||
"task.schedule",
|
||||
"task.update.assigned",
|
||||
"task.close",
|
||||
"task.reassign",
|
||||
"task.scope.change",
|
||||
"task.priority.change",
|
||||
"git.push.working",
|
||||
"git.push.protected",
|
||||
"git.merge.protected",
|
||||
"git.push.remote",
|
||||
"host.sudo",
|
||||
"review.request",
|
||||
"review.verdict",
|
||||
"record.write",
|
||||
"message.send",
|
||||
"message.external",
|
||||
"role.launch",
|
||||
"role.revoke",
|
||||
"role.end",
|
||||
"organization.change",
|
||||
"instance.name",
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
"credential.mint",
|
||||
"spend",
|
||||
"deploy",
|
||||
"data.delete",
|
||||
"network.expose",
|
||||
"account.create",
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
"library.write"
|
||||
],
|
||||
"vars": {
|
||||
"tracker.baseUrl": "http://127.0.0.1:3456",
|
||||
"tracker.pollSeconds": 60,
|
||||
"gitea.baseUrl": "https://git.example"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
"instance": "pm",
|
||||
"definition": "pm",
|
||||
"holder": "sage",
|
||||
"vars": {
|
||||
"harness": "pi",
|
||||
"model": "claude-opus-5-5"
|
||||
},
|
||||
"authority": {
|
||||
"task.create": "within",
|
||||
"project.bootstrap": "gated",
|
||||
"task.assign": "within",
|
||||
"task.schedule": "within",
|
||||
"task.update.assigned": "within",
|
||||
"task.close": "within",
|
||||
"task.reassign": "within",
|
||||
"task.scope.change": "cross",
|
||||
"task.priority.change": "cross",
|
||||
"git.push.working": "gated",
|
||||
"git.push.protected": "gated",
|
||||
"git.merge.protected": "gated",
|
||||
"git.push.remote": "gated",
|
||||
"host.sudo": "gated",
|
||||
"review.request": "gated",
|
||||
"review.verdict": "gated",
|
||||
"record.write": "within",
|
||||
"message.send": "within",
|
||||
"message.external": "gated",
|
||||
"role.launch": "gated",
|
||||
"role.revoke": "gated",
|
||||
"role.end": "gated",
|
||||
"organization.change": "gated",
|
||||
"instance.name": "gated",
|
||||
"role.hire": "gated",
|
||||
"role.fire": "gated",
|
||||
"credential.mint": "gated",
|
||||
"spend": "gated",
|
||||
"deploy": "gated",
|
||||
"data.delete": "gated",
|
||||
"network.expose": "gated",
|
||||
"account.create": "gated",
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
},
|
||||
{
|
||||
"instance": "cto",
|
||||
"definition": "cto",
|
||||
"holder": "darkwing",
|
||||
"vars": {
|
||||
"harness": "claude-code"
|
||||
},
|
||||
"authority": {
|
||||
"task.create": "gated",
|
||||
"project.bootstrap": "gated",
|
||||
"task.assign": "gated",
|
||||
"task.schedule": "gated",
|
||||
"task.update.assigned": "within",
|
||||
"task.close": "gated",
|
||||
"task.reassign": "gated",
|
||||
"task.scope.change": "cross",
|
||||
"task.priority.change": "gated",
|
||||
"git.push.working": "gated",
|
||||
"git.push.protected": "gated",
|
||||
"git.merge.protected": "gated",
|
||||
"git.push.remote": "gated",
|
||||
"host.sudo": "gated",
|
||||
"review.request": "within",
|
||||
"review.verdict": "gated",
|
||||
"record.write": "within",
|
||||
"message.send": "within",
|
||||
"message.external": "gated",
|
||||
"role.launch": "gated",
|
||||
"role.revoke": "gated",
|
||||
"role.end": "gated",
|
||||
"organization.change": "gated",
|
||||
"instance.name": "gated",
|
||||
"role.hire": "gated",
|
||||
"role.fire": "gated",
|
||||
"credential.mint": "gated",
|
||||
"spend": "gated",
|
||||
"deploy": "gated",
|
||||
"data.delete": "gated",
|
||||
"network.expose": "gated",
|
||||
"account.create": "gated",
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "within",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
},
|
||||
{
|
||||
"instance": "coder",
|
||||
"definition": "coder",
|
||||
"holder": null,
|
||||
"vars": {
|
||||
"harness": "pi",
|
||||
"limits.network": "none"
|
||||
},
|
||||
"authority": {
|
||||
"task.create": "gated",
|
||||
"project.bootstrap": "gated",
|
||||
"task.assign": "gated",
|
||||
"task.schedule": "gated",
|
||||
"task.update.assigned": "within",
|
||||
"task.close": "gated",
|
||||
"task.reassign": "gated",
|
||||
"task.scope.change": "cross",
|
||||
"task.priority.change": "gated",
|
||||
"git.push.working": "within",
|
||||
"git.push.protected": "gated",
|
||||
"git.merge.protected": "gated",
|
||||
"git.push.remote": "gated",
|
||||
"host.sudo": "gated",
|
||||
"review.request": "within",
|
||||
"review.verdict": "gated",
|
||||
"record.write": "within",
|
||||
"message.send": "within",
|
||||
"message.external": "gated",
|
||||
"role.launch": "gated",
|
||||
"role.revoke": "gated",
|
||||
"role.end": "gated",
|
||||
"organization.change": "gated",
|
||||
"instance.name": "gated",
|
||||
"role.hire": "gated",
|
||||
"role.fire": "gated",
|
||||
"credential.mint": "gated",
|
||||
"spend": "gated",
|
||||
"deploy": "gated",
|
||||
"data.delete": "gated",
|
||||
"network.expose": "gated",
|
||||
"account.create": "gated",
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
},
|
||||
{
|
||||
"instance": "reviewer",
|
||||
"definition": "reviewer",
|
||||
"holder": null,
|
||||
"vars": {},
|
||||
"authority": {
|
||||
"task.create": "gated",
|
||||
"project.bootstrap": "gated",
|
||||
"task.assign": "gated",
|
||||
"task.schedule": "gated",
|
||||
"task.update.assigned": "within",
|
||||
"task.close": "gated",
|
||||
"task.reassign": "gated",
|
||||
"task.scope.change": "gated",
|
||||
"task.priority.change": "gated",
|
||||
"git.push.working": "gated",
|
||||
"git.push.protected": "gated",
|
||||
"git.merge.protected": "gated",
|
||||
"git.push.remote": "gated",
|
||||
"host.sudo": "gated",
|
||||
"review.request": "gated",
|
||||
"review.verdict": "within",
|
||||
"record.write": "within",
|
||||
"message.send": "within",
|
||||
"message.external": "gated",
|
||||
"role.launch": "gated",
|
||||
"role.revoke": "gated",
|
||||
"role.end": "gated",
|
||||
"organization.change": "gated",
|
||||
"instance.name": "gated",
|
||||
"role.hire": "gated",
|
||||
"role.fire": "gated",
|
||||
"credential.mint": "gated",
|
||||
"spend": "gated",
|
||||
"deploy": "gated",
|
||||
"data.delete": "gated",
|
||||
"network.expose": "gated",
|
||||
"account.create": "gated",
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
},
|
||||
{
|
||||
"instance": "chief",
|
||||
"definition": "chief-of-staff",
|
||||
"holder": null,
|
||||
"vars": {},
|
||||
"authority": {
|
||||
"task.create": "gated",
|
||||
"project.bootstrap": "gated",
|
||||
"task.assign": "gated",
|
||||
"task.schedule": "gated",
|
||||
"task.update.assigned": "gated",
|
||||
"task.close": "gated",
|
||||
"task.reassign": "gated",
|
||||
"task.scope.change": "gated",
|
||||
"task.priority.change": "gated",
|
||||
"git.push.working": "gated",
|
||||
"git.push.protected": "gated",
|
||||
"git.merge.protected": "gated",
|
||||
"git.push.remote": "gated",
|
||||
"host.sudo": "gated",
|
||||
"review.request": "gated",
|
||||
"review.verdict": "gated",
|
||||
"record.write": "within",
|
||||
"message.send": "within",
|
||||
"message.external": "gated",
|
||||
"role.launch": "within",
|
||||
"role.revoke": "gated",
|
||||
"role.end": "within",
|
||||
"organization.change": "within",
|
||||
"instance.name": "within",
|
||||
"role.hire": "within",
|
||||
"role.fire": "within",
|
||||
"credential.mint": "gated",
|
||||
"spend": "gated",
|
||||
"deploy": "gated",
|
||||
"data.delete": "gated",
|
||||
"network.expose": "gated",
|
||||
"account.create": "gated",
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
}
|
||||
],
|
||||
"launch": {
|
||||
"by": "chief",
|
||||
"instances": [
|
||||
"coder",
|
||||
"reviewer"
|
||||
],
|
||||
"models": {
|
||||
"claude-opus-5-5": {
|
||||
"provider": "zai",
|
||||
"model": "claude-opus-5-5"
|
||||
},
|
||||
"claude-sonnet-5-5": {
|
||||
"provider": "zai",
|
||||
"model": "claude-sonnet-5-5"
|
||||
},
|
||||
"glm-5.3-flash": {
|
||||
"provider": "zai",
|
||||
"model": "glm-5.3-flash"
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"models": {
|
||||
"zai": {
|
||||
"claude-opus-5-5": 2,
|
||||
"claude-sonnet-5-5": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"threadIdleSeconds": 900
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"service": "gitea",
|
||||
"account": null,
|
||||
"role": "pm",
|
||||
"dateKind": "rotateBy",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"account": "bot-acme-pm",
|
||||
"role": "pm",
|
||||
"dateKind": "expires",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "gitea",
|
||||
"account": null,
|
||||
"role": "cto",
|
||||
"dateKind": "rotateBy",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"account": "bot-acme-cto",
|
||||
"role": "cto",
|
||||
"dateKind": "expires",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "gitea",
|
||||
"account": null,
|
||||
"role": "coder",
|
||||
"dateKind": "rotateBy",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"account": "bot-acme-coder",
|
||||
"role": "coder",
|
||||
"dateKind": "expires",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "gitea",
|
||||
"account": null,
|
||||
"role": "reviewer",
|
||||
"dateKind": "rotateBy",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"account": "bot-acme-reviewer",
|
||||
"role": "reviewer",
|
||||
"dateKind": "expires",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"account": "bot-acme-sync",
|
||||
"role": "tracker sync",
|
||||
"dateKind": "expires",
|
||||
"date": "2099-01-01",
|
||||
"state": "valid"
|
||||
}
|
||||
],
|
||||
"appearance": null
|
||||
},
|
||||
"at": "2026-10-10T12:00:00.000Z"
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -30,10 +30,13 @@ function tmp(t, prefix) {
|
||||
|
||||
// A canonical checkout (a .git directory) on branch refactor, with this
|
||||
// tree's files, the uncommitted ones included, and a RELEASE of its own.
|
||||
// A deploy tree links node_modules; .gitignore's node_modules/ matches only a
|
||||
// directory, so the link is listed as an untracked file. The copy never takes
|
||||
// node_modules from git (npm ci makes it), so the fixture leaves the link out.
|
||||
function fixtureRepo(t) {
|
||||
const dir = tmp(t, 'webui-copy-');
|
||||
const repo = join(dir, 'repo');
|
||||
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
||||
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation', ':(exclude,glob)**/node_modules'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
||||
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
|
||||
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
|
||||
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { businessDoc, REPO, REPO_ROLES, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { consoleReads } from '../src/reads.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { humanCall, consoleCall } from '../src/bus.mjs';
|
||||
|
||||
const fixed = Date.parse('2026-10-10T12:00:00.000Z');
|
||||
const golden = new URL('./fixtures/rename-a/business-response.json', import.meta.url);
|
||||
|
||||
test('v1 /api/business response preserves its complete JSON shape and redaction', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wire-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
const doc = businessDoc(root);
|
||||
writeJson(join(configDir, 'businesses/acme.json'), doc);
|
||||
const system = { configVersion: 1, environment: 'development', dataRoot, execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash', adapter: 'mock' } };
|
||||
const reads = consoleReads({ root: REPO, configDir, rolesDir: REPO_ROLES, system, business: 'acme', now: () => fixed });
|
||||
const server = await startServer({ port: 0, reads });
|
||||
t.after(() => server.close());
|
||||
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/business`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.text();
|
||||
assert.doesNotMatch(body, /placeholder-not-a-token|secret|"file"|"botId"/);
|
||||
assert.deepEqual(JSON.parse(body), JSON.parse(readFileSync(golden, 'utf8')));
|
||||
});
|
||||
|
||||
test('v1 Console human and owner reads send their exact business-bound IPC envelopes', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ipc-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const socket = join(root, 's');
|
||||
const received = [];
|
||||
const server = createServer(conn => {
|
||||
let input = '';
|
||||
conn.on('data', chunk => {
|
||||
input += chunk;
|
||||
if (!input.includes('\n')) return;
|
||||
received.push(JSON.parse(input));
|
||||
conn.end('{"ok":true,"result":{"answer":"fixture"}}\n');
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => server.listen(socket, resolve).once('error', reject));
|
||||
t.after(() => server.close());
|
||||
const env = { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR };
|
||||
const human = humanCall({ socket, business: 'acme', env });
|
||||
assert.deepEqual(await human('instances', {}), { answer: 'fixture' });
|
||||
assert.deepEqual(await human('limits', {}), { answer: 'fixture' });
|
||||
const owner = consoleCall({ socket, dataRoot: root, business: 'acme', env });
|
||||
assert.deepEqual(await owner('request.draft.console', { request: 'r1' }, { subject: { provider: 'local', iss: 'fixture', sub: 'owner' } }), { answer: 'fixture' });
|
||||
for (const [i, verb] of ['instances', 'limits'].entries()) {
|
||||
const { human: proof, ...rest } = received[i];
|
||||
assert.deepEqual(rest, { verb, args: {} });
|
||||
assert.deepEqual(Object.keys(proof), ['business', 'pid', 'startTime', 'nonce']);
|
||||
assert.equal(proof.business, 'acme');
|
||||
assert.ok(Number.isInteger(proof.pid));
|
||||
assert.match(String(proof.startTime), /^\d+$/);
|
||||
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
|
||||
}
|
||||
const { console: proof, ...rest } = received[2];
|
||||
assert.deepEqual(rest, { verb: 'request.draft.console', args: { request: 'r1' } });
|
||||
assert.deepEqual(Object.keys(proof), ['business', 'provider', 'iss', 'sub', 'pid', 'startTime', 'nonce']);
|
||||
assert.deepEqual([proof.business, proof.provider, proof.iss, proof.sub], ['acme', 'local', 'fixture', 'owner']);
|
||||
assert.ok(Number.isInteger(proof.pid));
|
||||
assert.match(String(proof.startTime), /^\d+$/);
|
||||
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -30,7 +30,10 @@ test('tester-port validates range tunnel main port and requires configured bus b
|
||||
assert.equal(r.status, 2); assert.match(r.stderr, /tester-socket needs the bus configured/);
|
||||
});
|
||||
|
||||
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
|
||||
// P19 #876: about 20 Console reads and chat calls in a row, each one TESTER_CLI round trip of two
|
||||
// node processes (the shim re-executes itself to carry its nonce). That is about 3 s idle and
|
||||
// 10 s at load 20 on 16 cores; 30 s left too little room on a busy machine, so the guard is 60 s.
|
||||
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 60000 }, async t => {
|
||||
const f = await testerFixture(t), port = testerSocketPath(f.f.dataRoot), log = join(f.root, 'spawn.jsonl');
|
||||
await new Promise(r=>f.web.close(r));
|
||||
const hook = join(f.root, 'spawn-hook.mjs');
|
||||
|
||||
+20
-4
@@ -32,6 +32,12 @@
|
||||
# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or
|
||||
# --root misused). A P check never changes the exit.
|
||||
#
|
||||
# --org ID is the same option as --business ID, in three scopes: this script's own arguments, the args
|
||||
# file's unit arguments before --, and its serve arguments after it. In each scope a spelling may appear
|
||||
# once, and the other spelling must carry the same value; the args file needs a selector in both of its
|
||||
# scopes, and all three must name the same business. The talk and precheck calls still pass --business,
|
||||
# which every copy takes.
|
||||
#
|
||||
# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none:
|
||||
# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and
|
||||
# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent
|
||||
@@ -39,10 +45,15 @@ set -uo pipefail
|
||||
|
||||
usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; }
|
||||
refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; }
|
||||
# selector.sh defines functions only, from this script's own root-owned checkout; parsing runs no program.
|
||||
case "${BASH_SOURCE[0]}" in */*) SELECTOR_SH="${BASH_SOURCE[0]%/*}/selector.sh" ;; *) SELECTOR_SH=./selector.sh ;; esac
|
||||
. "$SELECTOR_SH" || refuse "cannot read $SELECTOR_SH"
|
||||
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
|
||||
invocation_refused() { case "$1" in missing|flag|invalid) usage ;; *) refuse "$2" ;; esac; }
|
||||
BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;;
|
||||
--business|--org) selector_take invocation id invocation_refused business_id_ok "$1" "${@:2:1}"; BUSINESS="$2"; shift 2 ;;
|
||||
--args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;;
|
||||
--claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;;
|
||||
--node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;;
|
||||
@@ -67,14 +78,18 @@ st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE"
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) ||
|
||||
refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
|
||||
mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE"
|
||||
unit_refused() { refuse "$ARGS_FILE before --: $2"; }
|
||||
serve_refused() { refuse "$ARGS_FILE after --: $2"; }
|
||||
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_SOCKET=0
|
||||
copies=0 past=""
|
||||
copies=0 past="" served=""
|
||||
for ((i = 0; i < ${#ARGS[@]}; i++)); do
|
||||
a="${ARGS[i]}" v="${ARGS[i + 1]:-}"
|
||||
if [ -n "$past" ]; then
|
||||
case "$a" in
|
||||
--port) PORT="$v"; i=$((i + 1)) ;;
|
||||
--business) [ "$v" = "$BUSINESS" ] || refuse "serve business differs"; i=$((i + 1)) ;;
|
||||
--business|--org)
|
||||
selector_take serve id serve_refused business_id_ok "$a" "$v"
|
||||
[ "$v" = "$BUSINESS" ] || refuse "serve business differs"; served=1; i=$((i + 1)) ;;
|
||||
--tester-socket) TESTER_SOCKET=$((TESTER_SOCKET + 1)) ;;
|
||||
--tester-port) refuse "case1-accept.sh retires tester TCP; use --tester-socket" ;;
|
||||
*) refuse "unknown serve flag $a" ;;
|
||||
@@ -86,7 +101,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
|
||||
--pm) ;;
|
||||
--user) S_USER="$v"; i=$((i + 1)) ;;
|
||||
--home) S_HOME="$v"; i=$((i + 1)) ;;
|
||||
--business) A_BUSINESS="$v"; i=$((i + 1)) ;;
|
||||
--business|--org) selector_take unit id unit_refused business_id_ok "$a" "$v"; A_BUSINESS="$v"; i=$((i + 1)) ;;
|
||||
--node) NODE="$v"; i=$((i + 1)) ;;
|
||||
--claude) CLAUDE="$v"; i=$((i + 1)) ;;
|
||||
--profile) PROFILE="$v"; i=$((i + 1)) ;;
|
||||
@@ -95,6 +110,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
|
||||
esac
|
||||
done
|
||||
[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies"
|
||||
[ -n "$served" ] || refuse "$ARGS_FILE needs --business or --org after --"
|
||||
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
|
||||
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] ||
|
||||
refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy"
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
# case2-install.sh --business ID --business-file FILE --commit SHA
|
||||
# [--profile factory|library] [--personas DIR] [--home DIR]
|
||||
#
|
||||
# --org ID is the same option as --business ID, and --org-file FILE the same
|
||||
# as --business-file FILE. They are two families: each spelling once, and a
|
||||
# second spelling of a family must carry the same text.
|
||||
#
|
||||
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
|
||||
# SHA, whose npm ci installs the pinned harness; for library, the persona
|
||||
# import's install from the staging DIR; the business file at 0600; for
|
||||
@@ -39,11 +43,30 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
|
||||
|
||||
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
|
||||
# shellcheck source=scripts/selector.sh
|
||||
. "$REPO/scripts/selector.sh" || { echo "case2-install: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
|
||||
# The selectors (rename design r2): the id family and the file family are separate scopes. A
|
||||
# missing value is a usage error; a flag-shaped or invalid id is the business-id refusal below; a
|
||||
# flag-shaped file, a repeated spelling and a conflict say why. All exit 4, as before.
|
||||
id_refused() {
|
||||
case "$1" in
|
||||
missing) usage ;;
|
||||
flag|invalid) echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4 ;;
|
||||
*) echo "case2-install: $2" >&2; exit 4 ;;
|
||||
esac
|
||||
}
|
||||
file_refused() {
|
||||
case "$1" in
|
||||
missing) usage ;;
|
||||
*) echo "case2-install: $2" >&2; exit 4 ;;
|
||||
esac
|
||||
}
|
||||
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; }
|
||||
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
|
||||
--business-file) [ $# -ge 2 ] || usage; BFILE="$2"; shift 2 ;;
|
||||
--business|--org) BUSINESS="${2-}"; selector_take id id id_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
|
||||
--business-file|--org-file) selector_take file file file_refused - "$1" "${@:2:1}"; BFILE="$2"; shift 2 ;;
|
||||
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
|
||||
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
|
||||
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" || { echo "case2-precheck: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
|
||||
# --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
|
||||
|
||||
+43
-6
@@ -9,6 +9,12 @@
|
||||
# [--profile factory|library] [--proxy] [--pm] [--claude PATH]
|
||||
# -- SERVE FLAGS
|
||||
#
|
||||
# --org ID is the same option as --business ID (rename design r2), in each of
|
||||
# two scopes: this script's own flags before --, and the serve flags after it.
|
||||
# Each scope takes each spelling once, and a second spelling the same value;
|
||||
# nothing is counted across --. The serve flags must name the selector, it must
|
||||
# be the unit's business, and the unit renders it as --business ID.
|
||||
#
|
||||
# Root runs it from a root-owned checkout of the reviewed commit outside
|
||||
# <home>, never from the release, because <user> can write the release. It
|
||||
# writes the bus and Console units (and the proxy unit under --proxy) under
|
||||
@@ -77,14 +83,35 @@ abs_plain() {
|
||||
[ "$1" = / ] || [ "${1%/}" = "$1" ] || die "$2 must not end in /: $1"
|
||||
}
|
||||
|
||||
# shellcheck source=scripts/selector.sh
|
||||
. "$REPO/scripts/selector.sh"
|
||||
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
|
||||
# The outer selector: a missing value is a usage error, a flag-shaped or invalid one the
|
||||
# business-id refusal, and a repeated or conflicting one a usage error that says why.
|
||||
outer_refused() {
|
||||
case "$1" in
|
||||
missing) usage ;;
|
||||
flag|invalid) die "--business must be a business id: $BUSINESS" ;;
|
||||
*) echo "case2-unit: $2" >&2; exit 4 ;;
|
||||
esac
|
||||
}
|
||||
# The serve selector: every refusal names the serve flag, as the other serve flags do.
|
||||
serve_refused() {
|
||||
case "$1" in
|
||||
missing) die "serve flag $SERVE_NAME needs a value" ;;
|
||||
flag|invalid) die "serve $SERVE_NAME $SERVE_VALUE is not the unit's business $BUSINESS" ;;
|
||||
*) die "serve $2" ;;
|
||||
esac
|
||||
}
|
||||
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--profile|--user|--home|--business|--copy|--node|--claude)
|
||||
--business|--org) BUSINESS="${2-}"; selector_take outer id outer_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
|
||||
--profile|--user|--home|--copy|--node|--claude)
|
||||
[ $# -ge 2 ] || usage
|
||||
case "$1" in
|
||||
--profile) PROFILE="$2" ;; --user) USER_NAME="$2" ;; --home) HOME_DIR="$2" ;;
|
||||
--business) BUSINESS="$2" ;; --copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
|
||||
--copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
|
||||
--claude) CLAUDE="$2"; CLAUDE_SET=1 ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
@@ -346,7 +373,7 @@ tester_location() {
|
||||
serve_flags() {
|
||||
local -A seen=()
|
||||
local flags='' name value
|
||||
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0
|
||||
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0 selected=0
|
||||
local i=0 n=${#SERVE[@]}
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
name="${SERVE[$i]}"
|
||||
@@ -357,7 +384,7 @@ serve_flags() {
|
||||
--board) die "refusing --board: Case 2 runs no control board" ;;
|
||||
--sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
|
||||
--send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
|
||||
--port|--business) ;;
|
||||
--port|--business|--org) ;;
|
||||
--public-host|--auth-header|--allow-user|--proof-header|--proof-file)
|
||||
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
|
||||
*) die "unknown serve flag: $name" ;;
|
||||
@@ -369,10 +396,20 @@ serve_flags() {
|
||||
if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi
|
||||
[ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value"
|
||||
value="${SERVE[$((i + 1))]}"
|
||||
if [ "$name" = --business ] || [ "$name" = --org ]; then
|
||||
# One selector in the serve scope, bound to the unit's business, rendered once
|
||||
# as --business where it first appears.
|
||||
SERVE_NAME="$name" SERVE_VALUE="$value"
|
||||
selector_take serve id serve_refused business_id_ok "$name" "$value"
|
||||
[ "$value" = "$BUSINESS" ] || die "serve $name $value is not the unit's business $BUSINESS"
|
||||
[ "$selected" = 1 ] || flags="$flags --business $value"
|
||||
selected=1
|
||||
i=$((i + 2))
|
||||
continue
|
||||
fi
|
||||
case "$name" in
|
||||
--port) port="$value"
|
||||
[[ "$port" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$port" -le 1023 ] || die "--port must be 1..1023, so only the unit's capability can bind it: $port" ;;
|
||||
--business) [ "$value" = "$BUSINESS" ] || die "serve --business $value is not the unit's business $BUSINESS" ;;
|
||||
--public-host) public_host="$value"
|
||||
case "$value" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $value" ;; esac
|
||||
case "$value" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $value" ;; esac ;;
|
||||
@@ -392,7 +429,7 @@ serve_flags() {
|
||||
local required=(--port --business)
|
||||
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
|
||||
for name in "${required[@]}"; do
|
||||
[ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
|
||||
[ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required"
|
||||
done
|
||||
if [ "$PROFILE" = factory ]; then
|
||||
# #415: only the paired owner sign-in and writes. No preview header or
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
# [--subject TEXT] [--attribution LINE] [--business ID]
|
||||
# [--project ID]
|
||||
#
|
||||
# --org ID is --business ID (rename r2): each spelling once, both only with one value. A missing,
|
||||
# empty or flag-shaped value exits 4 before the config is read. The id keeps its old grammar: any
|
||||
# other text names businesses/<ID>.json, and a missing file exits 3.
|
||||
#
|
||||
# Checks, in order; each refusal names its reason on stderr and exits 2:
|
||||
# bus-schema the bus database's schema isn't the Store's
|
||||
# decision-not-found no decision with that id and one raise in the business
|
||||
@@ -48,6 +52,9 @@ REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
usage() { echo "usage: land-work.sh <decision-id> [--base refactor] [--dry-run] [--subject TEXT] [--attribution LINE] [--business ID] [--project ID]" >&2; exit 4; }
|
||||
fail() { echo "land-work: $1" >&2; exit "${2:-1}"; }
|
||||
refuse() { echo "land-work: refused: $1${2:+: $2}" >&2; exit 2; }
|
||||
# shellcheck source=scripts/selector.sh
|
||||
. "$SCRIPT_DIR/selector.sh" || fail "cannot read $SCRIPT_DIR/selector.sh" 4
|
||||
lw_selector_refused() { fail "$2" 4; }
|
||||
|
||||
DECISION=""; BASE="refactor"; DRY_RUN="no"; SUBJECT=""; ATTRIBUTION=""
|
||||
BUSINESS=""; PROJECT=""
|
||||
@@ -57,7 +64,7 @@ while [ $# -gt 0 ]; do
|
||||
--dry-run) DRY_RUN="yes"; shift ;;
|
||||
--subject) [ $# -ge 2 ] || usage; SUBJECT="$2"; shift 2 ;;
|
||||
--attribution) [ $# -ge 2 ] || usage; ATTRIBUTION="$2"; shift 2 ;;
|
||||
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
|
||||
--business|--org) selector_take args id lw_selector_refused - "$1" "${@:2:1}"; BUSINESS="$2"; shift 2 ;;
|
||||
--project) [ $# -ge 2 ] || usage; PROJECT="$2"; shift 2 ;;
|
||||
-*) usage ;;
|
||||
*) [ -z "$DECISION" ] || usage; DECISION="$1"; shift ;;
|
||||
|
||||
@@ -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-}"
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
# copy creates files only; wrapper is a separately Mos-gated live step.
|
||||
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
|
||||
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
|
||||
# check takes --org as --business (rename r2): each spelling once, and both only with one value.
|
||||
# Row 232: check --units external reads root's system units. copy --bundle makes the copy from a git
|
||||
# bundle on a host with no checkout, and wrapper --create writes the first wrapper on a new host.
|
||||
set -euo pipefail
|
||||
@@ -15,10 +16,13 @@ MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
|
||||
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | copy --bundle FILE SHA [--copies DIR] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] [--create] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID] [--units user|external]' >&2; exit "${1:-4}"; }
|
||||
die() { echo "stack-copy: $*" >&2; exit 1; }
|
||||
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
|
||||
# Selector refusals: a bad shape is a usage error (4); an invalid id exits 1, as check's own test does.
|
||||
copy_selector_refused() { case "$1" in invalid) die "$2" ;; *) echo "stack-copy: $2" >&2; exit 4 ;; esac; }
|
||||
copy_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
|
||||
parse() { while [ $# -gt 0 ]; do case "$1" in
|
||||
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
|
||||
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
|
||||
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
|
||||
--business|--org) [ "$CMD" = check ] || usage; selector_take check id copy_selector_refused copy_business_ok "$1" "${@:2:1}"; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
|
||||
--allow-worktree) [ "$CMD" = copy ] && [ -z "$BUNDLE" ] || usage; ALLOW_WORKTREE=1;shift;;
|
||||
--units) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; case "$2" in user|external) UNITS="$2";; *) usage;; esac; shift 2;;
|
||||
--create) [ "$CMD" = wrapper ] || usage; CREATE=1;shift;;
|
||||
@@ -179,6 +183,7 @@ check() {
|
||||
}
|
||||
# Sourced (stack-release.sh): define the functions, run nothing.
|
||||
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
|
||||
. "$REPO/scripts/selector.sh" || die "cannot read $REPO/scripts/selector.sh"
|
||||
. "$REPO/scripts/instance.sh"
|
||||
instance_args "$@"
|
||||
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
# Root runs it from a root-owned checkout of the reviewed commit, never from a copy, because the
|
||||
# stack's user can write the copies. The unit values come from /etc/mosaic-stack/<instance>.args,
|
||||
# which I2's install writes: one case2-unit.sh argument per line, the serve flags after `--`. Only
|
||||
# --copy changes, and the file changes only after confirm passes. The stack's user runs its steps
|
||||
# from its own clone, <home>/src/mosaic-stack, through sudo. Root reads none of that user's output
|
||||
# but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its step
|
||||
# log after a match against a fixed pattern and decides nothing on.
|
||||
# --copy changes, and the file changes only after confirm passes. --org is the same option as
|
||||
# --business in each of the file's two scopes, case2-unit.sh's own flags before `--` and the serve
|
||||
# flags after it: each scope takes each spelling once, a second spelling only with the same value,
|
||||
# and both scopes must name the business, the same one (rename design r2). The stack's user runs
|
||||
# its steps from its own clone, <home>/src/mosaic-stack, through sudo. Root reads none of that user's
|
||||
# output but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its
|
||||
# step log after a match against a fixed pattern and decides nothing on.
|
||||
#
|
||||
# --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3
|
||||
# from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses
|
||||
@@ -119,6 +122,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
|
||||
|
||||
# --- The args file and the stack's user ---
|
||||
|
||||
# The selector scopes: selector.sh defines functions only, from this root-owned checkout, as RENDER is.
|
||||
. "$REPO/scripts/selector.sh" || refuse checkout-unreadable "cannot read $REPO/scripts/selector.sh"
|
||||
args_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
|
||||
args_outer_refused() { refuse args-unreadable "$ETC/$INSTANCE.args before --: $2"; }
|
||||
args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"; }
|
||||
|
||||
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
|
||||
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY, ARGS_PROFILE and ARGS_PROXY, and the paths derived
|
||||
# from them.
|
||||
@@ -126,8 +135,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
|
||||
# --profile wins, as there), and the current one refuses it. A2 would stop both units, and A4 and
|
||||
# render-old would both fail on the same args, so a library file holding --send after -- refuses
|
||||
# here, before anything stops. The file is never rewritten; the operator removes the line.
|
||||
# Rename r2: case2-unit.sh also refuses a file whose serve flags name no selector or another
|
||||
# business, so that refuses here too. The serve flags are read with case2-unit.sh's grammar, which
|
||||
# says which of them take a value, so a value is never taken for a selector; a token that grammar
|
||||
# lacks refuses, as the render would.
|
||||
read_args() {
|
||||
local f="$ETC/$INSTANCE.args" st i copies=0 v profile=library
|
||||
local f="$ETC/$INSTANCE.args" st i sep copies=0 v profile=library served
|
||||
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
|
||||
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
|
||||
@@ -138,10 +151,13 @@ read_args() {
|
||||
--) break ;;
|
||||
--pm) ;;
|
||||
--proxy) ARGS_PROXY=1 ;;
|
||||
--user|--home|--business|--copy|--node|--profile|--claude)
|
||||
--business|--org)
|
||||
selector_take outer id args_outer_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}"
|
||||
BUSINESS="${ARGS[i + 1]}"; i=$((i + 1)) ;;
|
||||
--user|--home|--copy|--node|--profile|--claude)
|
||||
v="${ARGS[i + 1]:-}"
|
||||
case "${ARGS[i]}" in
|
||||
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --business) BUSINESS="$v" ;; --node) NODE="$v" ;;
|
||||
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --node) NODE="$v" ;;
|
||||
--copy) ARGS_COPY="$v"; copies=$((copies + 1)) ;;
|
||||
--profile) profile="$v" ;;
|
||||
esac
|
||||
@@ -149,6 +165,7 @@ read_args() {
|
||||
*) refuse args-unreadable "$f has ${ARGS[i]} before --, which case2-unit.sh does not take" ;;
|
||||
esac
|
||||
done
|
||||
sep=$i
|
||||
[ "$copies" = 1 ] || refuse args-unreadable "$f needs exactly one --copy before --, not $copies"
|
||||
ARGS_PROFILE="$profile"
|
||||
if [ "$profile" = library ]; then
|
||||
@@ -160,6 +177,18 @@ read_args() {
|
||||
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
|
||||
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$ARGS_COPY" =~ ^[0-9a-f]{40}$ ]] ||
|
||||
refuse args-unreadable "$f lacks a plain --user, --home, --business, --node or --copy"
|
||||
for ((i = sep + 1; i < ${#ARGS[@]}; i++)); do
|
||||
case "${ARGS[i]}" in
|
||||
--business|--org)
|
||||
selector_take serve id args_serve_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}"; i=$((i + 1)) ;;
|
||||
--port|--public-host|--auth-header|--allow-user|--proof-header|--proof-file) i=$((i + 1)) ;;
|
||||
--tester-socket|--sso|--send) ;;
|
||||
*) refuse args-unreadable "$f has ${ARGS[i]} after --, which case2-unit.sh does not take" ;;
|
||||
esac
|
||||
done
|
||||
served="$(selector_value serve)"
|
||||
[ -n "$served" ] || refuse args-unreadable "$f needs --business or --org after --, as case2-unit.sh does"
|
||||
[ "$served" = "$BUSINESS" ] || refuse args-unreadable "$f names business $BUSINESS before -- and $served after it"
|
||||
COPIES_DIR="$S_HOME/.local/share/mosaic-stack/copies"
|
||||
CLONE="$S_HOME/src/mosaic-stack"
|
||||
SR="$CLONE/scripts/stack-release.sh"
|
||||
|
||||
@@ -242,6 +242,46 @@ cp "$SANDBOX/c/args/businesses/fx.json" "$SANDBOX/c/nobus/businesses/"
|
||||
sed "s#$SANDBOX/c/args/data#$SANDBOX/c/nobus/data#" "$SANDBOX/c/args/config.json" > "$SANDBOX/c/nobus/config.json"
|
||||
expect nobus 3 "" "$D"
|
||||
|
||||
# ---- rename r2: --org is --business, each spelling once ----
|
||||
# Against a missing config: exit 4 and the message mean the parse refused before any read (3 otherwise).
|
||||
sel() { # want-rc want-stderr args...
|
||||
local want="$1" text="$2" rc=0 err
|
||||
shift 2
|
||||
err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$@" 2>&1 >/dev/null)" || rc=$?
|
||||
check "selector: $* exits $want: $text (got $rc: $err)" "$([ "$rc" -eq "$want" ] && [ "$err" = "$text" ]; echo $?)"
|
||||
}
|
||||
sel 4 "land-work: --business fx and --org other conflict" "$D" --business fx --org other
|
||||
sel 4 "land-work: --org other and --business fx conflict" "$D" --org other --business fx
|
||||
sel 4 "land-work: --business given twice" "$D" --business fx --business fx
|
||||
sel 4 "land-work: --org given twice" "$D" --org fx --org fx
|
||||
sel 4 "land-work: --org given twice" "$D" --org fx --business fx --org fx
|
||||
sel 4 "land-work: --org needs one value" "$D" --org
|
||||
sel 4 "land-work: --business needs one value" "$D" --business ""
|
||||
sel 4 "land-work: --org needs one value, not --dry-run" "$D" --org --dry-run
|
||||
sel 4 "land-work: --business needs one value, not -fx" "$D" --business -fx
|
||||
USAGE="$(bash scripts/land-work.sh 2>&1)"
|
||||
sel 4 "$USAGE" --org fx
|
||||
sel 4 "$USAGE" "$D" --org fx extra
|
||||
# A value another option takes is text, not a selector: the config read is what refuses.
|
||||
rc=0; err="$(MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" --subject --org 2>&1 >/dev/null)" || rc=$?
|
||||
check "selector: --subject --org is a subject, and the config read refuses (got $rc)" \
|
||||
"$([ "$rc" -eq 3 ] && [ "${err##*$'\n'}" = "land-work: system config is missing or invalid" ]; echo $?)"
|
||||
# Each spelling alone and both together name the business file read.
|
||||
# land-work never had an id grammar and keeps none, so text that is not an id is a missing file too.
|
||||
for args in "--business other" "--org other" "--org other --business other" "--org Other" "--business ../other"; do
|
||||
# shellcheck disable=SC2086
|
||||
expect args 3 "" "$D" $args
|
||||
check "selector: $args names businesses/${args##* }.json" \
|
||||
"$([ "$(tail -n1 "$SANDBOX/c/args/err")" = "land-work: no business file: $SANDBOX/c/args/businesses/${args##* }.json" ]; echo $?)"
|
||||
done
|
||||
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx
|
||||
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --business fx --org fx
|
||||
expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f" --org fx --business fx
|
||||
setup dry-org ok 42 "$W_OK"
|
||||
expect dry-org 0 "" "$D" --dry-run --org fx
|
||||
grep -q "no ref was written" "$SANDBOX/c/dry-org/out"
|
||||
check "selector: --org fx runs the dry run" "$?"
|
||||
|
||||
# ---- the bus database ----
|
||||
setup schema-table ok 42 "$W_OK"
|
||||
node -e 'const {DatabaseSync}=require("node:sqlite"); const db=new DatabaseSync(process.argv[1]); db.exec("CREATE TABLE extra(x)"); db.close()' \
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
Reference in new issue
Block a user