Files
stack/packages/cli/tests/org-templates.test.mjs
jason.woltje 13560660ae fix(packs): SP8 review round, single-pass placeholders, CLI hints, pins (#1873)
- template-save: one alternation pass replaces every literal, so an
  instance named root, org or agent never rewrites a placeholder already
  written in. checkPack's refusal message is passed on as the reason. A
  write or fsync failure removes the partial file it created.
- broker #templateFail drops a reason the credential check refuses.
- cli template words: humanTransport throws a CliError named by the bus
  code; a known code now gains its hint (the test transport throws the
  same CliError).
- template-http: the header notes serve.mjs strips /api/orgs/<id>.
- pins: the two .console verbs in console-decide and request-console;
  the packages/business/src re-export for template-save.mjs.
- tests: placeholder collision, a held-value key masked as [key], and a
  save after a real pack.apply.console keeping {id, version}.
2026-11-12 20:05:32 -06:00

77 lines
5.1 KiB
JavaScript

// 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));
});