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 [--tester] | check '; // 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 [--business ] [--json]', 'mosaic project system-types-set --types [--business ]', 'mosaic project create [--repo ] [--business ] [--tester]', 'mosaic project list [--archived] [--business ] [--json] [--tester]', 'mosaic project archive [--business ]', 'mosaic project unarchive [--business ]', 'A thread project named for a business repository must carry that repo: --repo .', '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 /projects// // (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); } }