Files
stack/packages/business/src/cli.mjs
T
jason.woltjeandClaude Opus 5.5 2d64c71eb2 feat(business): roles v2, business and project files, variable layers (row 36, S1, darkwing)
Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730).
build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and
checked 34/34. Integration gate on an export of HEAD plus the patch:
business 60/60 on Node 24 and 26, every package test and every
scripts/test-*.sh green, test-task 98/98 with the live-provider cases.
Conductor, queue, conversation and discord confirmed in git worktrees of
HEAD with and without the patch, identical results. Lead decision 63
accepts the vocabulary location, the example path and the business
branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 17:09:07 -05:00

139 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
// mosaic business validate <business>
// mosaic business resolve <business> <instance> [--project <id>]
//
// validate loads the business file, every role definition it uses, the
// project files that exist under its declared roots, and stat-checks every
// credential reference. resolve prints one role instance's resolved record.
// Both print JSON on stdout and problems on stderr.
//
// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential
// reference); 3 system config problem; 4 usage, or a required file missing.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BusinessError } from "./errors.mjs";
import { loadBusiness } from "./business.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { checkCredentialRef } from "./credentials.mjs";
import { systemVars, resolveInstance } from "./resolve.mjs";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const USAGE = "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]";
function fail(code, message) {
process.stderr.write(`mosaic business: ${message}\n`);
process.exit(code);
}
function loadSystem() {
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
encoding: "utf8",
maxBuffer: 1024 * 1024,
});
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
try {
return JSON.parse(proc.stdout);
} catch {
fail(3, "system config: mosaic-config printed something that isn't JSON");
}
}
function rolesDir() {
return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
}
// Check credential references; print warnings; return the problem list.
function checkRefs(refs, forbiddenRoots) {
const problems = [];
const warnings = [];
for (const ref of refs) {
const result = checkCredentialRef(ref, { forbiddenRoots });
problems.push(...result.problems);
warnings.push(...result.warnings);
}
return { problems, warnings };
}
function forbiddenRoots(config, business) {
return [REPO, config.dataRoot, ...Object.values(business.projects).map((p) => p.root)];
}
function validate(args) {
if (args.length !== 1) fail(4, USAGE);
const config = loadSystem();
const system = systemVars(config);
const business = loadBusiness(args[0], { rolesDir: rolesDir() });
const projects = {};
const warnings = [];
for (const [id, entry] of Object.entries(business.projects)) {
const file = projectFilePath(entry.root);
if (!existsSync(file)) {
projects[id] = "absent";
warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
continue;
}
const project = loadProject(entry.root);
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`);
for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance });
projects[id] = "valid";
}
const refs = [
...Object.values(business.tracker.sync.credentials),
...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
];
const checked = checkRefs(refs, forbiddenRoots(config, business));
warnings.push(...checked.warnings);
const instances = {};
for (const instance of Object.keys(business.roles)) {
instances[instance] = resolveInstance({ system, business, instance }).digest;
}
for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`);
}
function resolveCommand(args) {
let project = null;
const rest = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--project") {
if (!args[i + 1] || project !== null) fail(4, USAGE);
project = args[++i];
} else rest.push(args[i]);
}
if (rest.length !== 2) fail(4, USAGE);
const [businessId, instance] = rest;
const config = loadSystem();
const business = loadBusiness(businessId, { rolesDir: rolesDir() });
let loaded = null;
if (project !== null) {
const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
loaded = loadProject(entry.root);
}
const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
if (checked.problems.length > 0) {
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
}
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
}
const [verb, ...args] = process.argv.slice(2);
try {
if (verb === "validate") validate(args);
else if (verb === "resolve") resolveCommand(args);
else fail(4, USAGE);
} catch (error) {
if (error instanceof BusinessError) fail(error.exitCode, error.message);
throw error;
}