Files
stack/packages/cli/tests/project.test.mjs
T
jason.woltje 3e21d239b3 feat(bus): owner project archive and unarchive (#797)
Jason's owner report 2026-11-06: no way to archive or delete projects.
Archive is the reversible half; delete stays out of scope because project
and run records are write-once evidence.

- project.archive / project.unarchive: human-only (project.create's gate
  minus the tester carve-out; tester, user and agent refused). State is the
  latest row of a new append-only project_archives journal (3s->3t, additive;
  the rows are the audit trail), so projects itself never changes and nothing
  is deleted. Repeats refuse project-already-archived / project-not-archived.
- project.list hides archived projects until {includeArchived:true}, which
  flags each with archived:true; a user still lists granted projects only.
- While archived, thread.create, thread.send, room.open and room.send refuse
  project-archived; every existing record stays readable and unarchive
  restores everything.
- Console: new public/project-archive.js (+css) and project-archive-http.mjs
  routes (chatHTTP's write fence); chat.js gains two one-line hooks,
  index.html one script and one link tag; styling only through the shell's
  .project-archive-control / .project-archived-filter hooks.
- CLI: mosaic project archive|unarchive <name-or-id> and list --archived.
- stack-release COUNTED/LISTED gain project_archives (schema order); schema
  step-count pins and migration fixtures updated for 3t.

bus 1601/1601 (Node 24; Node 26 has the pre-existing project-file socket
stall), webui 582/582, plugins 132/132, cli 1191/1194 (two pre-existing
load flakes of the row253 C4 and land_work families; both pass isolated and
the base commit's full suite fails four of the same family).
2026-11-05 23:50:51 -06:00

128 lines
7.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, existsSync } 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 } from './helpers.mjs';
import { writeJson } from '../../business/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 <business> <project> 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 <name>/);
}
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`);
});