Adds the system-type kind with five modules (coding, creative-writing, marketing, montage, business-management), an org-level enabled list validated fail-closed, project selections limited to the org's list, read verbs, and bus migration 3t-3u. REQ-SYS-2 toolsets are designed and deferred: they need a role authority contract.
127 lines
7.7 KiB
JavaScript
127 lines
7.7 KiB
JavaScript
import { configDir, loadBusiness } from '../../organization/src/index.mjs';
|
|
import { checkProject, initProject, ProjectError, testerProjectArea, recordContext } from '../../project/src/index.mjs';
|
|
import { loadSystem, rolesDir } from './config.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { shortId } from './format.mjs';
|
|
import { parseArgs } from './thread.mjs';
|
|
import { basename, join } from 'node:path';
|
|
import { lstatSync } from 'node:fs';
|
|
import { readRegistry, resolveOrg } from '../../organization/src/org-registry.mjs';
|
|
import { refuseInsideAgent } from './transport.mjs';
|
|
|
|
export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester] | check <business> <project>';
|
|
// Row 128 (#1622): create and list work on the bus record (project.create, project.list); init
|
|
// and check above work on the repository on disk and never contact the bus, except that
|
|
// init --tester (row 175, #1673) binds the tester once before it writes.
|
|
export const PROJECT_BUS_USAGE = [
|
|
'mosaic project system-types <name-or-id> [--business <id>] [--json]',
|
|
'mosaic project system-types-set <name-or-id> --types <comma-separated-ids> [--business <id>]',
|
|
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
|
|
'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
|
|
'mosaic project archive <name-or-id> [--business <id>]',
|
|
'mosaic project unarchive <name-or-id> [--business <id>]',
|
|
'A thread project named for a business repository must carry that repo: --repo <name>.',
|
|
'list --archived includes archived projects, each marked; archive is reversible and deletes nothing.',
|
|
].join('\n ');
|
|
|
|
export async function projectBusCommand(args, io, callFor) {
|
|
const [sub, ...rest] = args;
|
|
const parsed = parseArgs(rest, PROJECT_BUS_USAGE, sub === 'create'
|
|
? { flags: ['--tester'], values: ['--repo', '--business'] }
|
|
: sub === 'list'
|
|
? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
|
|
: { flags: sub === 'system-types' ? ['--json'] : [], values: sub === 'system-types-set' ? ['--business', '--types'] : ['--business'] });
|
|
const one = ['create', 'archive', 'unarchive', 'system-types', 'system-types-set'].includes(sub);
|
|
if (parsed.positional.length !== (one ? 1 : 0))
|
|
throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
|
|
const call = callFor(parsed);
|
|
if (sub === 'create') {
|
|
const [name] = parsed.positional;
|
|
let made;
|
|
try {
|
|
made = await call('project.create', { name, ...(parsed.values['--repo'] === undefined ? {} : { repo: parsed.values['--repo'] }) });
|
|
} catch (error) {
|
|
if (error.code === 'project-name-is-repo')
|
|
throw new CliError(`${JSON.stringify(name)} is a repository in the business file; add --repo ${name} to make its thread project, or pick another name`, 2);
|
|
throw error;
|
|
}
|
|
return io.stdout.write(`created project ${name} id ${made.id}\n`);
|
|
}
|
|
if (sub === 'system-types' || sub === 'system-types-set') {
|
|
const projects = await call('project.list', { includeArchived: true });
|
|
const ref = parsed.positional[0];
|
|
const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref);
|
|
if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}`, 2);
|
|
const write = sub === 'system-types-set';
|
|
if (write && parsed.values['--types'] === undefined) throw new CliError('--types is required (an empty string clears the list)', 4);
|
|
const systemTypes = parsed.values['--types'] ? parsed.values['--types'].split(',') : [];
|
|
const result = await call(write ? 'project.system-types.set' : 'project.system-types', { project: hit.id, ...(write ? { systemTypes } : {}) });
|
|
return io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
}
|
|
// #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included,
|
|
// so an archived project can be named for unarchive.
|
|
if (sub === 'archive' || sub === 'unarchive') {
|
|
const [ref] = parsed.positional;
|
|
const projects = await call('project.list', { includeArchived: true });
|
|
const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref);
|
|
if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}; see mosaic project list`, 2);
|
|
try {
|
|
await call(`project.${sub}`, { project: hit.id });
|
|
} catch (error) {
|
|
if (error.code === 'project-already-archived') throw new CliError(`project ${hit.name} is already archived`, 2);
|
|
if (error.code === 'project-not-archived') throw new CliError(`project ${hit.name} is not archived`, 2);
|
|
throw error;
|
|
}
|
|
return io.stdout.write(`${sub === 'archive' ? 'archived' : 'unarchived'} project ${hit.name} id ${shortId(hit.id)}\n`);
|
|
}
|
|
const projects = await call('project.list', parsed.flags.has('--archived') ? { includeArchived: true } : {});
|
|
if (parsed.flags.has('--json')) return io.stdout.write(`${JSON.stringify(projects, null, 2)}\n`);
|
|
io.stdout.write(projects.length === 0 ? 'no projects\n' : projects.map(p => `${p.name}${p.archived ? ' archived' : ''} ${p.repo ?? '-'} ${shortId(p.id)}\n`).join(''));
|
|
}
|
|
|
|
// Row 175 (#1673, Q173, mos-0872 Q2): `init --tester` may run inside an agent. Before anything
|
|
// touches the disk it refuses a repository outside <dataRoot>/projects/<business>/
|
|
// (tester-project-area, exit 2), then binds the tester with one agents read, so tester-off and
|
|
// tester-required refuse (exit 3), then commits the layout as `tester`.
|
|
export async function projectCommand(args, io, deps = {}, callFor) {
|
|
const tester = args[0] === 'init' && args.filter(arg => arg === '--tester').length === 1;
|
|
const words = tester ? args.filter(arg => arg !== '--tester') : args;
|
|
if (words.length !== 3 || !['init', 'check'].includes(words[0]) || words.some(arg => arg.startsWith('--')))
|
|
throw new CliError(PROJECT_USAGE, 4);
|
|
const [verb, id, project] = words;
|
|
if (verb === 'init' && !tester) refuseInsideAgent(io.env);
|
|
try {
|
|
const system = deps.system ?? loadSystem({ env: io.env });
|
|
const business = loadBusiness(id, { dir: configDir(io.env), rolesDir: rolesDir(io.env) });
|
|
const dir = configDir(io.env), named = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
|
|
let orgRoot = null;
|
|
let registryPresent = false;
|
|
if (named) { try { lstatSync(join(dir, 'org-runtime.json')); registryPresent = true; } catch (e) { if (e.code !== 'ENOENT') throw e; } }
|
|
if (named && registryPresent) {
|
|
const registry = await readRegistry({ configDir: dir });
|
|
if (registry?.orgs?.[id]) {
|
|
const org = await resolveOrg({ configDir: dir, dataRoot: system.dataRoot, instance: named[1] }, id);
|
|
if (org.state !== 'active') throw new CliError('organization is not active', 3);
|
|
orgRoot = org.root;
|
|
}
|
|
}
|
|
if (tester && orgRoot) throw new CliError('organization project init --tester is unavailable', 3);
|
|
const context = recordContext({ business, project, dataRoot: system.dataRoot, orgRoot });
|
|
if (tester) {
|
|
testerProjectArea(context);
|
|
await callFor({ positional: [], flags: new Set(['--tester']), values: { '--business': id } })('agents', {});
|
|
}
|
|
if (verb === 'init') {
|
|
const result = await initProject(context, tester ? { author: 'tester' } : {});
|
|
io.stdout.write(`initialized ${result.repo}\ncommit ${result.commit}\n`);
|
|
} else {
|
|
const result = await checkProject(context);
|
|
if (!result.ok) throw new CliError(result.problems.join('\n'), 2);
|
|
io.stdout.write(`checked ${result.repo}: valid\n`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof CliError) throw error;
|
|
throw new CliError(error.message, error instanceof ProjectError ? 2 : 3);
|
|
}
|
|
}
|