- An --as target that lands on another pack item's name refuses invalid-request (definitions and workflows); the preview no longer shows two adds for one path. - A workflow step role naming one of the pack's definitions follows its --as rename, so an inline definition renamed at preview applies. - GET /api/board stays the Board read; only POST /api/board is the pack board write. - Tombstones key on pack id, origin and project: a saved template reusing a shipped id is another pack. - An apply that adds nothing and proposes what the last apply proposed writes nothing, so retries do not fill the 256 packs entries. - mosaic org pack list refuses inside an agent run like the other words. - Tests pin coder-1's credential lines, skills.notEnabled and the member's unsuffixed pack.apply and board.set refusals; the CONSOLE_VERBS pins in two bus tests name the five pack verbs.
184 lines
19 KiB
JavaScript
184 lines
19 KiB
JavaScript
import { readFile, writeFile } from 'node:fs/promises';
|
|
import { configDir } from '../../organization/src/organization-config.mjs';
|
|
import { basename } from 'node:path';
|
|
import { listOrgs, orgLock, resolveOrg } from '../../organization/src/org-registry.mjs';
|
|
import { organizationWriter } from '../../organization/src/organization.mjs';
|
|
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
|
|
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
|
import { installedStarterPacks } from '../../plugins/src/starter-pack.mjs';
|
|
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
|
import { loadSystem, orgPaths, rolesDir } from './config.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { busStart, busStopForDelete, copyRemoteFor } from './org-bus.mjs';
|
|
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
|
import { consoleBusRestart } from './org-console.mjs';
|
|
import { busExit, humanTransport, refuseInsideAgent } from './transport.mjs';
|
|
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
|
|
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
|
|
// Starter packs SP4 (design section 8): the owner's pack, board and workflow words.
|
|
export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]';
|
|
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
|
|
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
|
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
|
|
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
|
|
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
|
|
// agent-guard (#1873): archive and delete stop the organization's bus, so an agent run is refused
|
|
// as it is for `mosaic bus stop`: after the usage checks, before any config is read, and checking
|
|
// this process's own environment as well as io.env. Restore starts the organization's bus (E1, P1-4),
|
|
// so it is refused the same way unless --no-bus is given; `restore --no-bus` starts nothing.
|
|
function refuseAgentRun(io) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
|
|
// E1 (P1-4): the bus a restore starts is the one `mosaic bus start ID --dev-local-owner --detach`
|
|
// starts, after that verb's checks. The checks run before the restore, so a refusal changes nothing.
|
|
function restoreBusCheck(system, env) {
|
|
try { assertBuildStart({ devOwner: true }); assertDevInstance(system, env); }
|
|
catch (e) { throw new CliError(`restore starts the bus as the local dev owner, and ${e.message}; restore with --no-bus, then start the bus with mosaic bus start`, 3); }
|
|
}
|
|
// Starter packs SP4: `mosaic org pack|board|workflow`. Every word is the owner's and goes through the
|
|
// organization's bus as its human, so it runs only from a human shell: the bus holds the credential
|
|
// store whose held-secret check each preview and write needs, and the open hire decisions a preview
|
|
// reads. A refusal arrives as its code; a FILE's text is never echoed.
|
|
async function packFile(file) {
|
|
try { return JSON.parse(await readFile(file, 'utf8')); }
|
|
catch (e) { throw new CliError(`cannot read ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); }
|
|
}
|
|
// deps.callFor or deps.transport stand in for the bus in tests; otherwise the call is callFor's in cli.mjs.
|
|
function packCall(id, io, deps) {
|
|
if (typeof deps.callFor === 'function') return deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
|
|
if (typeof deps.transport === 'function') return deps.transport(id);
|
|
return async (verb, args) => {
|
|
const dir = configDir(io.env);
|
|
const match = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
|
|
if (!match) throw new CliError('organization commands require an instance config directory', 3);
|
|
const system = deps.system ?? loadSystem({ env: io.env, instance: match[1] });
|
|
const org = await resolveOrg({ configDir: dir, dataRoot: system.dataRoot, instance: match[1], env: io.env, system }, id);
|
|
if (org.state !== 'active') throw new CliError('organization is not active', 3);
|
|
return humanTransport({ socket: orgPaths(org.root).socket, business: id, env: io.env })(verb, args);
|
|
};
|
|
}
|
|
const PACK_REFUSALS = {
|
|
'pack-plan-stale': 'the organization or the pack changed since the preview; preview again and apply its digest',
|
|
'revision-conflict': 'the organization file changed while this ran; run it again',
|
|
'credential-leak': 'the request holds a credential the organization\'s bus holds; nothing was written',
|
|
'invalid-request': 'the bus refused the request: a bad argument, a project that is not a code project, or a pack that fails the composition check',
|
|
'human-required': 'only the organization\'s owner, from a human shell, may do this',
|
|
'starter-pack-not-found': 'no installed, shipped or saved pack has that name',
|
|
};
|
|
async function packsCommand(args, io, deps) {
|
|
const pos = [], values = {}, as = [];
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (['--project', '--digest', '--as'].includes(a)) {
|
|
if (!args[i + 1] || args[i + 1].startsWith('--') || (a !== '--as' && values[a] !== undefined)) throw new CliError(`${a} needs one value`, 4);
|
|
if (a === '--as') as.push(args[++i]); else values[a] = args[++i];
|
|
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
|
|
else pos.push(a);
|
|
}
|
|
const allowed = (...keys) => { for (const k of [...Object.keys(values), ...(as.length ? ['--as'] : [])]) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
|
|
const project = values['--project'] === undefined ? {} : { project: values['--project'] };
|
|
const [word, sub, id, name, file] = pos;
|
|
// Every word is the human's: an agent run refuses before a file is read, a pack is loaded or the bus is called.
|
|
refuseAgentRun(io);
|
|
let verb, request, revised = false;
|
|
if (word === 'pack' && sub === 'list' && pos.length === 2) { allowed(); verb = 'packs'; request = {}; }
|
|
else if (word === 'pack' && ['preview', 'apply'].includes(sub) && pos.length === 4) {
|
|
allowed('--project', '--as', ...(sub === 'apply' ? ['--digest'] : []));
|
|
if (sub === 'apply' && !values['--digest']) throw new CliError('--digest is required: apply the digest a preview printed', 4);
|
|
verb = `pack.${sub}`;
|
|
request = { pack: name, ...project, ...(as.length ? { as } : {}), ...(sub === 'apply' ? { digest: values['--digest'] } : {}) };
|
|
} else if (word === 'board' && sub === 'set' && pos.length === 4) { allowed('--project'); verb = 'board.set'; revised = true; request = { board: await packFile(name), ...project }; }
|
|
else if (word === 'workflow' && sub === 'set' && pos.length === 5) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: await packFile(file), ...project }; }
|
|
else if (word === 'workflow' && sub === 'remove' && pos.length === 4) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: null, ...project }; }
|
|
else throw new CliError(PACKS_USAGE, 4);
|
|
// pack list names no organization, so it reads what the bus's `packs` verb reads (the shipped and
|
|
// installed packs, never a saved template) from this instance's data root, as `system-types
|
|
// installed` does.
|
|
if (verb === 'packs') {
|
|
const system = deps.system ?? loadSystem({ env: io.env, instance: devOwnerInstance(io.env) });
|
|
const { packs } = await installedStarterPacks({ dataRoot: system.dataRoot, rolesDir: rolesDir(io.env) });
|
|
io.stdout.write(JSON.stringify({ packs: packs.map(({ id, origin, pack }) => ({ id, origin, pack })) }, null, 2) + '\n');
|
|
return;
|
|
}
|
|
const call = packCall(id, io, deps);
|
|
if (revised) {
|
|
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
|
|
request = { revision: writer.read(id).revision, ...request };
|
|
}
|
|
let result;
|
|
try { result = await call(verb, request); }
|
|
catch (e) {
|
|
if (typeof e?.code !== 'string' || e.code === '' || e instanceof CliError) throw e;
|
|
const why = PACK_REFUSALS[e.code];
|
|
const error = new CliError(why ? `${e.code}: ${why}` : e.code, busExit(e.code));
|
|
error.code = e.code;
|
|
throw error;
|
|
}
|
|
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
}
|
|
const busStartVerb = id => `mosaic bus start ${id} --dev-local-owner --detach`;
|
|
export async function orgCommand(args, io, deps = {}) {
|
|
try {
|
|
// #1873 T1: the definitions verbs take their own options.
|
|
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
|
|
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
|
|
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
|
|
if (['pack', 'board', 'workflow'].includes(args[0])) return await packsCommand(args, io, deps);
|
|
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
|
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
|
|
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
|
|
if (words[0] === 'template' && words[1] === 'render' && words.length === 3) { allowed([], ['--fixture', '--output']); result = await templateRender(words[2], { fixture: p.values['--fixture'] }); await writeFile(need('--output'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); io.stdout.write(`rendered ${words[2]} to ${p.values['--output']}\n`); return; }
|
|
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
|
|
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
|
|
if (words[0] === 'reload') refuseInsideAgent(io.env);
|
|
const revision = (r, required) => { if (r === undefined && !required) return undefined; if (!/^[1-9][0-9]*$/.test(r ?? '')) throw new CliError(r === undefined ? '--if-revision is required' : 'invalid revision', 4); return Number(r); };
|
|
let lifecycle = null;
|
|
if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) refuseAgentRun(io); }
|
|
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); }
|
|
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
|
|
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
|
|
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
|
|
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
|
|
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
|
|
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
|
|
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) });
|
|
else if (words[0] === 'restore' && words.length === 2) {
|
|
const startBus = !p.flags.has('--no-bus');
|
|
if (startBus) restoreBusCheck(system, io.env);
|
|
result = await restoreOrg(ctx, words[1], lifecycle);
|
|
// The restore stands whatever the bus does. A bus that does not start is said so, with the verb
|
|
// that starts it, and the command exits 3 after printing the restore receipt.
|
|
if (startBus) {
|
|
try { const started = await (deps.busStart ?? busStart)({ ...ctx, env: io.env }, words[1], { detach: true, devLocalOwner: true, pm: false }); result = { ...result, bus: { outcome: 'started', pid: started.pid } }; }
|
|
catch (e) {
|
|
// A host that died left its lock behind; `bus start` refuses it until `bus recover` clears it.
|
|
const message = e instanceof CliError ? e.message : 'the bus did not start', dead = /^stale /.test(message);
|
|
result = { ...result, bus: { outcome: 'failed', message, next: dead ? `mosaic bus recover ${words[1]}; ${busStartVerb(words[1])}` : busStartVerb(words[1]) } };
|
|
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
throw new CliError(`${words[1]} was restored, but its bus did not start: ${message}; ${dead ? `clear the dead host with mosaic bus recover ${words[1]}, then start it with` : 'start it with'} ${busStartVerb(words[1])}`, 3);
|
|
}
|
|
}
|
|
}
|
|
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx), ...lifecycle });
|
|
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
|
|
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
|
|
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
|
|
else if (words[0] === 'show' && words.length === 2) { allowed(['--json'], []); result = await showOrg(ctx, words[1]); }
|
|
else if (words[0] === 'list' && words.length === 1) { allowed(['--json'], []); for (const entry of await listOrgs(ctx)) if (entry.state === 'active' || entry.state === 'archived') accepted.push(await showOrg(ctx, entry.id, { includeArchived: true })); result = accepted; }
|
|
else if ((words[0] === 'queue' && words.length === 3) || (words[0] === 'project' && words[1] === 'repo' && words.length === 5)) {
|
|
const project = words[0] === 'project', op = words[project ? 2 : 1], id = words[project ? 3 : 2], name = project ? words[4] : null;
|
|
if (op === 'show') { allowed(['--json'], []); result = project ? await projectRepoShow(ctx, id, name) : await queueShow(ctx, id); }
|
|
else if (op === 'init') { allowed(['--local-only', '--accept-local-loss-risk'], ['--remote']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const opts = { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk') }; result = project ? await projectRepoInit(ctx, id, name, opts) : await queueInit(ctx, id, opts); }
|
|
// #1873 P1-7: change the project's repository (remote URL, or remote to local-only and back).
|
|
// The same URL rules and check as init; origin follows; nothing is pushed.
|
|
else if (op === 'set' && project) { allowed(['--json', '--local-only', '--accept-local-loss-risk'], ['--remote', '--if-revision']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const ifRevision = revision(p.values['--if-revision'], true); result = await projectRepoSet(ctx, id, name, { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk'), ifRevision }); }
|
|
else throw new CliError(ORG_USAGE, 4);
|
|
} else throw new CliError(ORG_USAGE, 4);
|
|
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
} catch (e) {
|
|
if (e instanceof CliError) throw e;
|
|
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
|
|
// and code, never by the host path in its message (PR #1916).
|
|
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
|
|
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
|
|
}
|
|
}
|