import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { main, run } from '../src/cli.mjs'; import { fixture, humanEnv, io, tmp, orgFixture } from './helpers.mjs'; import { writeJson } from '../../organization/tests/helpers.mjs'; test('project init/check use declared business repo and never contact the bus', async t => { const root = tmp(t), f = fixture(root); f.doc.projects.stack.repo = join(root, 'records'); writeJson(join(root, 'config/businesses/acme.json'), f.doc); const x = io(humanEnv(f.env)); const deps = { transport: () => assert.fail('local project command contacted bus') }; await main(['project', 'init', 'acme', 'stack'], x, deps); assert.ok(existsSync(join(root, 'records/spec/north-star.md'))); const checked = io(humanEnv(f.env)); assert.equal(await run(['project', 'check', 'acme', 'stack'], checked, deps), 0); assert.match(checked.out.text, /valid|checked/); writeFileSync(join(root, 'records/unknown'), 'bad'); const bad = io(humanEnv(f.env)); assert.equal(await run(['project', 'check', 'acme', 'stack'], bad, deps), 2); assert.match(bad.err.text, /unknown/); }); test('project commands validate args and refuse undeclared projects and agent init', async t => { const root = tmp(t), f = fixture(root); await assert.rejects(main(['project', 'init', 'acme', 'missing'], io(humanEnv(f.env)), {}), /declare/); await assert.rejects(main(['project', 'init', 'acme', 'stack'], io({ ...f.env, CODEX_THREAD_ID: 'fixture-agent' }), {}), /agent/i); for (const args of [[], ['other', 'acme', 'stack'], ['init', 'acme'], ['check', 'acme', 'stack', '--write']]) assert.equal(await run(['project', ...args], io(humanEnv(f.env)), {}), 4); }); // Row 128 (#1622): create and list are bus verbs; they take their own arguments, and init and // check keep theirs (a name for create is not the init/check pair). test('project create and list validate their arguments before any bus call', async t => { const root = tmp(t), f = fixture(root); const deps = { transport: () => assert.fail('a usage error reached the bus') }; for (const args of [['create'], ['create', 'a', 'b'], ['create', 'a', '--repo'], ['create', 'a', '--repo', 'x', '--repo', 'y'], ['create', 'a', '--write'], ['list', 'extra'], ['list', '--repo', 'x']]) { const x = io(humanEnv(f.env)); // --business is given, so only the arity check can produce the exit 4 (callFor would too, without it). assert.equal(await run(['project', ...args, '--business', 'acme'], x, deps), 4, args.join(' ')); assert.match(x.err.text, /mosaic project create /); } const listed = []; const managementReads = [], transport = () => async verb => verb === 'plugins' ? (managementReads.push(verb), { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }) : (listed.push(verb), []); const x = io(humanEnv(f.env)); assert.equal(await run(['project', 'list', '--business', 'acme'], x, { transport }), 0); assert.deepEqual(listed, ['project.list']); assert.equal(x.out.text, 'no projects\n'); assert.deepEqual(managementReads, ['plugins']); }); test('project dispatcher reaches the human CLI', async t => { const root = tmp(t), f = fixture(root); const result = spawnSync(new URL('../../../scripts/mosaic', import.meta.url).pathname, ['project'], { encoding: 'utf8', env: humanEnv(f.env), }); assert.equal(result.status, 4); assert.match(result.stderr, /mosaic project/); }); // #797 MVP3: archive and unarchive resolve a name-or-id against the full list (archived // included, so an archived project can be named), then call their verb once. list --archived // includes archived projects, each marked. test('project archive and unarchive validate args, resolve name-or-id, and map refusals', async t => { const root = tmp(t), f = fixture(root); const A = '10000000-0000-4000-8000-000000000001', B = '20000000-0000-4000-8000-000000000002'; const projects = [ { id: A, at: '2026-11-06T00:00:00.000Z', name: 'live', repo: null }, { id: B, at: '2026-11-06T00:00:00.000Z', name: 'shelved', repo: null, archived: true }, ]; const calls = []; const transport = () => async (verb, args) => { if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; calls.push([verb, args]); if (verb === 'project.list') return projects; if (verb === 'project.archive') return { project: args.project, archived: true }; if (verb === 'project.unarchive') return { project: args.project, archived: false }; throw Object.assign(new Error(verb), { code: 'bus-refused' }); }; for (const args of [['archive'], ['unarchive'], ['archive', 'a', 'b'], ['archive', 'x', '--repo', 'y'], ['archive', '--archived', 'x']]) assert.equal(await run(['project', ...args, '--business', 'acme'], io(humanEnv(f.env)), { transport }), 4, args.join(' ')); assert.deepEqual(calls, []); const archived = io(humanEnv(f.env)); assert.equal(await run(['project', 'archive', 'live', '--business', 'acme'], archived, { transport }), 0); assert.deepEqual(calls.at(-1), ['project.archive', { project: A }]); assert.equal(archived.out.text, `archived project live id ${A.slice(0, 8)}\n`); const restored = io(humanEnv(f.env)); assert.equal(await run(['project', 'unarchive', B, '--business', 'acme'], restored, { transport }), 0); assert.deepEqual(calls.at(-1), ['project.unarchive', { project: B }]); assert.equal(restored.out.text, `unarchived project shelved id ${B.slice(0, 8)}\n`); for (const [args, code] of [[['archive', 'missing'], 2], [['unarchive', 'live'], 2], [['archive', 'shelved'], 2]]) { const transport2 = () => async (verb, args2) => { if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; if (verb === 'project.list') return projects; throw Object.assign(new Error('refused'), { code: args[0] === 'archive' && args[1] === 'shelved' ? 'project-already-archived' : args[1] === 'missing' ? undefined : 'project-not-archived', }); }; const x = io(humanEnv(f.env)); assert.equal(await run(['project', ...args, '--business', 'acme'], x, { transport: transport2 }), code, args.join(' ')); assert.match(x.err.text, args[1] === 'missing' ? /no project is named/ : args[0] === 'archive' ? /already archived/ : /not archived/); } }); test('project list --archived includes and marks archived projects', async t => { const root = tmp(t), f = fixture(root); const A = '10000000-0000-4000-8000-000000000001', B = '20000000-0000-4000-8000-000000000002'; const calls = []; const transport = () => async (verb, args) => { if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; calls.push([verb, args]); return [ { id: A, at: '2026-11-06T00:00:00.000Z', name: 'live', repo: null }, { id: B, at: '2026-11-06T00:00:00.000Z', name: 'shelved', repo: null, archived: true }, ]; }; const plain = io(humanEnv(f.env)); assert.equal(await run(['project', 'list', '--business', 'acme'], plain, { transport }), 0); assert.deepEqual(calls.at(-1), ['project.list', {}]); const archived = io(humanEnv(f.env)); assert.equal(await run(['project', 'list', '--archived', '--business', 'acme'], archived, { transport }), 0); assert.deepEqual(calls.at(-1), ['project.list', { includeArchived: true }]); assert.equal(archived.out.text, `live - ${A.slice(0, 8)}\nshelved archived - ${B.slice(0, 8)}\n`); }); test('organization project init and check use the organization data root', async t => { const { f, org } = await orgFixture(t); const out = io(humanEnv(f.env)); assert.equal(await run(['project', 'init', 'acme', 'stack'], out, {}), 0, out.err.text); assert.ok(existsSync(join(org.root, 'projects/acme/stack/spec/north-star.md'))); assert.equal(existsSync(join(f.dataRoot, 'projects/acme/stack')), false); const checked = io(humanEnv(f.env)); assert.equal(await run(['project', 'check', 'acme', 'stack'], checked, {}), 0, checked.err.text); }); test('organization project init refuses existing instance-root records without changing them', async t => { const { f, org } = await orgFixture(t); const old = join(f.dataRoot, 'projects/acme/stack'), expected = join(org.root, 'projects/acme/stack'); mkdirSync(old, { recursive: true }); writeFileSync(join(old, 'keep'), 'preserve'); const out = io(humanEnv(f.env)); assert.notEqual(await run(['project', 'init', 'acme', 'stack'], out, {}), 0); assert.ok(out.err.text.includes(old)); assert.ok(out.err.text.includes(expected)); assert.equal(readFileSync(join(old, 'keep'), 'utf8'), 'preserve'); assert.equal(existsSync(expected), false); });