Files
stack/packages/cli/src/instances.mjs
T
jason.woltje 98d29769e2 bus, cli: tester reads and the W1-W7 tester writes, recorded as tester; scripts/mosaic sends plugins, name and names to the CLI (row 175, #1673)
TESTER_VERBS gains usage, names, limits, plugins, instances, project.list,
thread.list, name.set and project.create. A tester reads limits, plugins
and instances and sends plugins.set, instances.set and routes.set, each
logged with actor tester. The project file verbs refuse a tester
tester-refused. The broker and CLI drop the [mos test]
prefix rule (mos-0872 Q1). The CLI takes --tester on the seven reads and
the W1-W7 write forms and refuses it (exit 4) elsewhere, bus start and
stop included.

project init --tester checks the project area, refuses the area root
itself, binds the tester with one agents read, then commits the layout as
tester (mos-0872 Q2). scripts/mosaic sends plugins, name, names, follow,
threads and thread to packages/cli (sage-1081). The row 164 pins are
untouched.

Built by Dewey on 9e74925c2 (three commits to 989699f7c, manifest
2e30f2a1...29d5f), approved by Filbert in round 3 (#1673 comment 29279),
Sage's gate green on 716ddcf1e plus the patch.
2026-10-19 19:04:50 -05:00

122 lines
6.9 KiB
JavaScript

// `mosaic business instances` and `mosaic business instance add|set` (row 165, #1662, Q171,
// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's instances
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]";
// The bus answers a refusal with its code alone, so the reason a human reads is here.
export const REASONS = {
"organization-credentials-out-of-scope": "this command binds no credentials, so it refuses a definition that declares them " +
"and a definition change on an instance that holds them; that stays a hand edit of the business file",
"organization-instance-exists": "the business already has that instance; change it with `mosaic business instance set`",
"organization-unknown-instance": "the business has no such instance; add it with `mosaic business instance add`",
"organization-definition-refused": "this command neither adds nor changes an assistant, and moves no instance to the assistant " +
"definition; an assistant changes only through `mosaic limits set` with an assistants patch",
"organization-launch-not-configured": "the business file has no launch block, so launch membership can't change",
"organization-no-change": "the business file already says that",
"organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it",
"organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why",
"revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`",
};
export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it";
const NO_RESTART = "no restart: the launcher reads the change at its next launch check";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
out.flags.add(arg);
} else if (["--definition", "--vars", "--revision"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
// The args instances.set takes, from `instance add|set`. A usage error is exit 4 before any call.
function setArgs(parsed) {
const [, op, , instance] = parsed.positional;
const { values, flags } = parsed;
const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined;
if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") ||
(flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) ||
(op === "set" && !values["--definition"] && !values["--vars"] && launch === undefined)) {
throw new CliError(INSTANCES_USAGE, 4);
}
const args = { revision: values["--revision"], instance, op: op === "add" ? "add" : "change" };
if (values["--definition"]) args.definition = values["--definition"];
if (values["--vars"]) {
try {
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
} catch (error) {
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
}
}
if (launch !== undefined) args.launch = launch;
return args;
}
export function formatInstances(view) {
const lines = [`business ${view.business}, revision ${view.revision}`];
for (const [name, entry] of Object.entries(view.instances)) {
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean);
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
}
const definitions = Object.entries(view.definitions);
const accepted = definitions.filter(([, d]) => d.accepted).map(([name]) => name);
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`);
if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`);
return `${lines.join("\n")}\n`;
}
// args are the words after `mosaic business`. callFor(parsed) returns the human's call(verb, args).
export async function instancesCommand(args, io, callFor) {
const parsed = parse(args);
const sub = parsed.positional[0];
const business = parsed.positional[sub === "instances" ? 1 : 2];
if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
let request;
if (sub === "instances") {
if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) {
throw new CliError(INSTANCES_USAGE, 4);
}
} else request = setArgs(parsed);
try {
const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } });
if (!request) {
const view = await call("instances", {});
return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view));
}
const result = await call("instances.set", request);
const entry = result.instances[request.instance];
io.stdout.write([
`business ${result.business}: ${request.op === "add" ? "added" : "changed"} ${request.instance} (${entry.definition}${entry.launched ? ", launched" : ""})`,
`revision ${result.revision}`,
`transaction ${result.transaction}`,
result.restart ? RESTART_NEEDED : NO_RESTART,
].join("\n") + "\n");
} catch (error) {
const code = error.code;
if (Object.hasOwn(REASONS, code)) {
const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2);
refused.code = code;
throw refused;
}
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}