GET /api/workspace on the dev-local-owner Console: one snapshot of projects, sessions, inbox, tasks and per-org metrics across every organization the owner belongs to. Membership reuses the org list's admission (organizations.members); each org is read over the same per-org options the org pages use, with one deadline per org (4000 ms) and a state (ok, down, starting, timeout, invalid) with a time. Every row carries its org id; decide commands carry --org. Granted users and the tester get 403 owner-only. Polling at 10 s, no push. mosaic console restart and mosaic org reload <id> call the Console's F3 routes (POST /api/console/restart, POST /api/orgs/<id>/restart/bus) so gating, proof and trail are the Console's own; both refuse inside an agent run first. The cross-org assistant read is designed and stopped: it needs a new broker read verb or role authority.
43 lines
6.9 KiB
JavaScript
43 lines
6.9 KiB
JavaScript
import { readFile, writeFile } from 'node:fs/promises';
|
|
import { configDir } from '../../organization/src/organization-config.mjs';
|
|
import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs';
|
|
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
|
|
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
|
import { loadSystem } from './config.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { busStopForDelete } from './org-bus.mjs';
|
|
import { consoleBusRestart } from './org-console.mjs';
|
|
import { refuseInsideAgent } from './transport.mjs';
|
|
export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--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] | 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'].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 }; }
|
|
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
|
|
export async function orgCommand(args, io, deps = {}) {
|
|
try {
|
|
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 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 (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
|
|
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
|
|
// 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); }
|
|
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; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); }
|
|
}
|