// Starter packs SP8: `mosaic org template save|list`. Each word goes through the organization's bus as // its human; save --preview writes nothing, save --digest writes the previewed JSON under the owner's // account, and template render keeps its own words. import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, lstatSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { main } from '../src/cli.mjs'; import { ORG_USAGE, TEMPLATE_USAGE } from '../src/org.mjs'; import { bootConfig, loadSystem } from '../src/config.mjs'; import { CliError } from '../src/errors.mjs'; import { busExit } from '../src/transport.mjs'; import { BusError } from '../../bus/src/broker.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; import { fixture, io, tmp } from './helpers.mjs'; async function setup(t) { const root = tmp(t); const f = fixture(root, 'acme'); const file = join(root, 'config/businesses/acme.json'); const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }); const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization }); t.after(() => b.close()); const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true }); const calls = []; const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => { calls.push([verb, Object.keys(args).sort()]); try { return structuredClone(await b.broker.request(human, { verb, args })); } // What humanTransport throws for a bus refusal: a CliError named by the code alone. catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new CliError(e.code, busExit(e.code)), { code: e.code }); } } }; const run = async (args, env = f.env) => { const x = io(env); await main(['org', ...args], x, deps); return JSON.parse(x.out.text); }; const saved = (name) => join(boot.organization.dir, 'templates', f.doc.human, `${name}.json`); return { root, f, file, calls, run, saved }; } test('SP8 mosaic org template save: --preview writes nothing, --digest writes the previewed JSON, a second save refuses, list shows it', async (t) => { const s = await setup(t); assert.ok(ORG_USAGE.includes(TEMPLATE_USAGE.slice('mosaic org '.length))); const before = readFileSync(s.file); const preview = await s.run(['template', 'save', 'acme', 'acme-shape', '--preview']); assert.match(preview.digest, /^[0-9a-f]{64}$/); assert.ok(Array.isArray(preview.freeText) && Array.isArray(preview.replacements)); assert.ok(!existsSync(s.saved('acme-shape')), 'preview writes no file'); const out = await s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]); assert.deepEqual(out, { name: 'acme-shape', scope: 'org', digest: preview.digest }); assert.equal(readFileSync(s.saved('acme-shape'), 'utf8'), preview.json); assert.equal(lstatSync(s.saved('acme-shape')).mode & 0o777, 0o600); assert.deepEqual(readFileSync(s.file), before, 'save writes nothing to the organization file'); await assert.rejects(s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]), (e) => e.code === 'invalid-request' && /already saved/.test(e.message)); await assert.rejects(s.run(['template', 'save', 'acme', 'other', '--digest', preview.digest]), (e) => e.code === 'pack-plan-stale' && /preview again/.test(e.message)); const listed = await s.run(['template', 'list', 'acme']); assert.deepEqual(listed.templates.map((x) => x.name), ['acme-shape']); assert.deepEqual(s.calls.map(([verb, keys]) => [verb, keys.join(',')]), [ ['template.save', 'name,preview'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.list', ''], ]); }); test('SP8 template words: a bad shape prints the usage; an agent shell refuses before the bus is called; template render keeps its words', async (t) => { const s = await setup(t); for (const args of [['template', 'save', 'acme', 'x'], ['template', 'save', 'acme', 'x', '--preview', '--digest', 'a'.repeat(64)], ['template', 'save', 'acme'], ['template', 'list'], ['template', 'list', 'acme', '--preview']]) { await assert.rejects(s.run(args), (e) => e.exitCode === 4 && e.message === TEMPLATE_USAGE, args.join(' ')); } await assert.rejects(s.run(['template', 'save', 'acme', 'x', '--force']), (e) => e.exitCode === 4 && /unknown option --force/.test(e.message)); await assert.rejects(s.run(['template', 'save', 'acme', '..', '--preview']), (e) => e.code === 'invalid-request'); const agent = { ...s.f.env, MOSAIC_RUN_ID: 'run-1' }; const calls = s.calls.length; for (const args of [['template', 'save', 'acme', 'x', '--preview'], ['template', 'save', 'acme', 'x', '--digest', 'a'.repeat(64)], ['template', 'list', 'acme']]) { await assert.rejects(s.run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message), args.join(' ')); } assert.equal(s.calls.length, calls); // template render is still routed to its own words (it needs --output). await assert.rejects(s.run(['template', 'render', 'acme']), (e) => e.exitCode === 4 && /--output/.test(e.message)); });