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 { 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]... | pack hire ID PACK [--project P] | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]'; // #1873 SP8: saving the organization, or one project, as a template; the owner's alone. export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list ID'; export const ORG_USAGE = 'mosaic org 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) + ' | ' + TEMPLATE_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', 'starter-pack-invalid': 'the pack fails its own checks or the composition check; nothing was written', 'starter-pack-template-changed': 'a template the pack names changed since it was loaded; preview again', 'not-configured': 'this organization\'s bus has no organization writer configured', // Ruling 2026-11-14: apply and pack hire hire the pack's proposals. These come back as a hire's code. 'pack-not-applied': 'that pack has no packs entry in this organization or project; preview and apply it first', 'pack-hire-route-missing': 'neither the chief of staff nor the assistant has a harness and model to give a hire; set both in either one\'s vars, then run pack hire', 'no-chief-of-staff': 'the organization has no chief of staff to raise its hires', 'right-required': 'hiring needs the org.hire right', }; // E2E6 fix 3: humanTransport throws a CliError whose message and .code are the bus code alone. A code // a table knows gains its hint, as template save's do; a next line it carried is kept. Any other // CliError (bus-not-running with its own next line, a usage error) passes unchanged, and a bare coded // error (a test's call) becomes a CliError named by its code. function hinted(e, ...tables) { const code = typeof e?.code === 'string' ? e.code : ''; const table = tables.find(t => Object.hasOwn(t, code)); if (!table && (code === '' || e instanceof CliError)) return e; const error = new CliError(table ? `${code}: ${table[code]}` : code, busExit(code), { next: e.next }); error.code = code; return error; } 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 === 'pack' && sub === 'hire' && pos.length === 4) { // Ruling 2026-11-14: hire the proposals of a pack already applied, the same loop apply runs. allowed('--project'); verb = 'pack.hire'; request = { pack: name, ...project }; } 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. 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) { throw hinted(e, PACK_REFUSALS); } io.stdout.write(JSON.stringify(result, null, 2) + '\n'); } // #1873 SP8: template save previews (--preview) or writes the previewed JSON (--digest); template // list prints the owner's saved templates. Both call the bus as the owner's human; an agent run is // refused before the bus is called. const TEMPLATE_REFUSALS = { 'template-secret-suspected': 'a field the template would keep holds a credential the organization\'s bus holds; remove it and preview again (nothing was written)', 'pack-plan-stale': 'the organization or a template changed since the preview; preview again and save its digest', 'template-exists': 'a template of this name is already saved for your account; nothing was written', 'invalid-request': 'the bus refused the request: a bad name, an unknown project, or an organization the template cannot hold', 'human-required': 'only the organization\'s owner, from a human shell, may do this', 'not-configured': 'the templates directory is missing or not private to this instance\'s user', 'starter-pack-invalid': 'the template would not be a valid pack: more than 8 hires, or a definition wider than its template', }; async function templateCommand(args, io, deps) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--project', '--digest'].includes(a)) { if (!args[i + 1] || args[i + 1].startsWith('--') || values[a] !== undefined) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a === '--preview') { if (flags.has(a)) throw new CliError('--preview is given twice', 4); flags.add(a); } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } const [, sub, id, name] = pos; refuseAgentRun(io); let verb, request; if (sub === 'save' && pos.length === 4 && flags.has('--preview') !== (values['--digest'] !== undefined)) { verb = 'template.save'; request = { name, ...(values['--project'] === undefined ? {} : { project: values['--project'] }), ...(flags.has('--preview') ? { preview: true } : { digest: values['--digest'] }) }; } else if (sub === 'list' && pos.length === 3 && !flags.size && !Object.keys(values).length) { verb = 'template.list'; request = {}; } else throw new CliError(TEMPLATE_USAGE, 4); let result; try { result = await packCall(id, io, deps)(verb, request); } catch (e) { const error = hinted(e, TEMPLATE_REFUSALS, PACK_REFUSALS); // E2E6 fix 8: a taken name says what to do next. if (error.code === 'template-exists' && error.next === undefined) error.next = `preview and save under another name, or see the saved names with mosaic org template list ${id}`; 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); if (args[0] === 'template' && ['save', 'list'].includes(args[1])) return await templateCommand(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); } 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] === '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); } }