fix(cli,bus,organization): E2E6 lane f6c, refusal lines name the next step (#1873)

1. Stale bus host lock refusals from bus status, start, stop and restart
   name mosaic bus recover ORG.
2. org definitions: unknown org, unknown or malformed name and bad id give
   one refusal or usage line, no stack trace, no host path.
3. Pack, board and workflow refusals print their hint as template save does;
   tested with the real CliError transport shape.
4. console start and stop clear a console/start.lock whose named supervisor
   (pid and start time) is gone; a live or unnamed holder refuses.
5. Template save keeps free text verbatim and writes no placeholders; the
   preview lists each field that mentions an org-specific name.
6. A request the shim never sent (no socket, connect refused) exits 5 and
   the CLI reports bus-not-running with the start and recover line.
7. autonomy-precondition carries the check that failed, including on the
   hire paths.
8. Saving under an existing template name refuses template-exists with a
   next step; refusal table entry added, channel-proof pins moved.
This commit is contained in:
jason.woltje committed 2026-11-13 20:14:21 -06:00
1 parent 23b5e24ebc
commit 23e504f331
30 files changed
+564 -154

No files matched your search

+1
View File
@@ -845,6 +845,7 @@ export async function run(argv, io = { env: process.env, stdin: process.stdin, s
if (!(error instanceof CliError)) throw error;
io.stderr.write(`mosaic: ${error.message}\n`);
if (OTHER_TREE_CODES.has(error.code)) io.stderr.write(`mosaic: ${OTHER_TREE}\n`);
if (typeof error.next === "string" && error.next !== "") io.stderr.write(`mosaic: next: ${error.next}\n`);
return error.exitCode;
}
}
+5 -1
View File
@@ -3,11 +3,15 @@
// input, a decision or option that doesn't exist); 3 refused (system or
// business config problem, the broker or notifier refused, the human proof
// refused, a host already running); 4 usage.
//
// E2E6: `next` is an optional next-step line. run() prints it after the message as
// `mosaic: next: ...`; the message itself stays as callers (the Console among them) match it.
export class CliError extends Error {
constructor(message, exitCode = 2) {
constructor(message, exitCode = 2, { next } = {}) {
super(message);
this.name = "CliError";
this.exitCode = exitCode;
if (typeof next === "string" && next !== "") this.next = next;
}
}
+7 -1
View File
@@ -76,6 +76,11 @@ const same = (a, b) => a?.pid === b?.pid && a?.startTime === b?.startTime && a?.
// tried again, like one that goes before the check. `found` runs once the
// lock is found and before it is read, `between` after the stale lock is
// read; both are for the tests. Exported for the tests.
// E2E6 fix 1: every stale-lock refusal names the verb that clears a host that died.
export const recoverNext = (business) =>
typeof business === "string" && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(business)
? `if the bus host died, clear it with mosaic bus recover ${business}; recover refuses while the host still runs`
: "if the bus host died, clear it with mosaic bus recover <org>; recover refuses while the host still runs";
export function claimHostLock(dataRoot, state, { found = () => {}, between = () => {}, refuseStale = false } = {}) {
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
const file = hostLock(dataRoot);
@@ -94,7 +99,8 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
const prior = readState(file, found);
if (!prior) continue;
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
if (refuseStale) throw new CliError("stale bus host lock", 3);
// E2E6 fix 1: the message stays (the Console matches it); the next line names the clearing verb.
if (refuseStale) throw new CliError("stale bus host lock", 3, { next: recoverNext(prior.business) });
between();
try {
renameSync(file, aside);
+11 -9
View File
@@ -8,7 +8,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveOrg, withOrgLifecycle } from '../../organization/src/org-registry.mjs';
import { orgPaths } from './config.mjs';
import { cmdlineOf, hostFile, hostLock, hostStatus, readHostState, startTimeOf, stopHost } from './host.mjs';
import { cmdlineOf, hostFile, hostLock, hostStatus, readHostState, recoverNext, startTimeOf, stopHost } from './host.mjs';
import { CliError } from './errors.mjs';
import { readBootStatus } from './boot-status.mjs';
@@ -27,20 +27,22 @@ async function target(ctx, id, allowDeleting = false) {
return { ...org, paths: orgPaths(org.root) };
}
function checkedState(org) {
// E2E6 fix 1: each refusal keeps its message and adds the recover line as its next step.
const stale = message => { throw new CliError(message, 3, { next: recoverNext(org.id) }); };
const s = hostStatus(org.root);
let lock = null;
try { lock = JSON.parse(readFileSync(hostLock(org.root), 'utf8')); }
catch (e) { if (e.code !== 'ENOENT') fail('bus host lock is unreadable'); }
if (lock && (!Number.isSafeInteger(lock.pid) || typeof lock.startTime !== 'string' || startTimeOf(lock.pid) !== lock.startTime)) fail('stale bus host lock');
if (s.host && (!s.host.live || s.host.business !== org.id)) fail('stale or mismatched bus host state');
catch (e) { if (e.code !== 'ENOENT') stale('bus host lock is unreadable'); }
if (lock && (!Number.isSafeInteger(lock.pid) || typeof lock.startTime !== 'string' || startTimeOf(lock.pid) !== lock.startTime)) stale('stale bus host lock');
if (s.host && (!s.host.live || s.host.business !== org.id)) stale('stale or mismatched bus host state');
if (s.host) {
const args = cmdlineOf(s.host.pid) ?? [], i = args.indexOf(CLI);
if (i < 0 || args[i + 1] !== 'bus' || args[i + 2] !== 'start' || args[i + 3] !== org.id) fail('bus host identity mismatch');
if (i < 0 || args[i + 1] !== 'bus' || args[i + 2] !== 'start' || args[i + 3] !== org.id) stale('bus host identity mismatch');
}
if (lock && (!s.host || lock.pid !== s.host.pid || lock.business !== org.id)) fail('bus host lock does not match host state');
if (s.writerLock && !s.writerLock.live) fail('stale bus writer lock');
if (!s.host && s.writerLock) fail('bus writer lock has no matching host');
if (s.host && !lock) fail('bus host lock is missing');
if (lock && (!s.host || lock.pid !== s.host.pid || lock.business !== org.id)) stale('bus host lock does not match host state');
if (s.writerLock && !s.writerLock.live) stale('stale bus writer lock');
if (!s.host && s.writerLock) stale('bus writer lock has no matching host');
if (s.host && !lock) stale('bus host lock is missing');
return s;
}
export function probeBusSocket(path, { signal, deadline } = {}) {
+57 -6
View File
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
import { constants, closeSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
@@ -60,12 +60,63 @@ function removeStale(ctx, s, warn) {
warn(`mosaic: stale console state moved to ${aside}\n`);
return true;
}
function takeLock(ctx) {
// E2E6 fix 4: what a start.lock that already exists names. Null when it names no holder: unreadable,
// empty (a holder between its open and its write), not a regular file of this user, or a bad pid or
// start time. Such a lock is never cleared.
function lockHolder(path) {
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch { return null; }
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || st.size > 4096) return null;
const data = JSON.parse(readFileSync(fd, 'utf8'));
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !/^\d+$/.test(data.startTime)) return null;
return { pid: data.pid, startTime: data.startTime, dev: st.dev, ino: st.ino };
} catch { return null; } finally { closeSync(fd); }
}
// True when the process the lock names (pid and start time) is gone. A /proc read that cannot settle
// it counts as live, so an uncertain holder is never cleared.
function holderGone(holder) {
let p;
try { p = processState(holder.pid); } catch { return false; }
return !p.alive || p.startTime !== holder.startTime;
}
// Moves aside and removes the lock the holder check read, and only that file: a lock another start
// took in between is put back and the clear refuses.
function clearLock(path, holder) {
const aside = `${path}.dead-${randomBytes(4).toString('hex')}`;
try { renameSync(path, aside); } catch { fail('console start lock changed while it was being cleared; run the command again'); }
const st = lstatSync(aside);
if (st.dev !== holder.dev || st.ino !== holder.ino) {
// Put back without replacing: if a third start took the path meanwhile, the aside stays for a human.
let restored = false;
try { linkSync(aside, path); restored = true; } catch { /* left aside */ }
if (restored) rmSync(aside, { force: true });
fail('console start lock changed while it was being cleared; run the command again');
}
rmSync(aside, { force: true });
}
function takeLock(ctx, warn = () => {}) {
checkDir(ctx);
const path = join(directory(ctx), 'start.lock');
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
let fd;
for (let cleared = false; fd === undefined;) {
try { fd = openSync(path, 'wx', 0o600); }
catch (e) {
if (e?.code !== 'EEXIST' || cleared) fail('console start lock exists');
// E2E6 fix 4: a lock whose named holder (pid and start time) is gone is cleared once, and the
// take tried again. A live holder, or a lock that names none, refuses and stays.
const holder = lockHolder(path);
if (!holder) fail('console start lock exists and names no holder; remove console/start.lock under the data root once no console start or stop runs');
if (!holderGone(holder)) fail(`console start lock is held by process ${holder.pid}, which still runs; wait for its start or stop to finish`);
clearLock(path, holder);
warn(`mosaic: cleared a console start lock left by process ${holder.pid}, which is gone\n`);
cleared = true;
}
}
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
// supervisor it killed (clearDeadLock) and no other.
// supervisor it killed (clearDeadLock), and a later start or stop can clear it once that holder is gone.
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
@@ -242,7 +293,7 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
// re-check P1). A Restart Console relaunch goes through relaunch(), not here.
refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env);
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
const release = takeLock(ctx);
const release = takeLock(ctx, warn);
let run, args;
try {
const prior = state(ctx);
@@ -266,7 +317,7 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
return result;
}
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
const release = takeLock(ctx);
const release = takeLock(ctx, warn);
try {
const s = state(ctx);
if (!s || removeStale(ctx, s, warn)) return { stopped: false };
+29
View File
@@ -8,6 +8,7 @@ import { configDir } from '../../organization/src/organization-config.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
import { checkDefinition } from '../../organization/src/definitions.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { busExit, refuseInsideAgent } from './transport.mjs';
@@ -59,6 +60,29 @@ function refusal(code, verb, name, rev, read) {
return error;
}
// E2E6 fix 2: a read or a write's file check that fails says so in one line: an unknown
// organization, a missing definition or a bad file, by code. No stack trace, and no host path: a raw
// filesystem error is named by its operation and code, and any absolute path left in a message is cut.
const NAME_SHOWN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
function missing(id, name) { return `${NAME_SHOWN.test(name ?? '') ? `definitions declares no ${name}` : 'definitions declares no definition by that name'}; list them with mosaic org definitions ${id} list`; }
function pathFree(message) { return String(message).replace(/(^|[\s'"(=])\/[^\s'"),]*/g, '$1<path>'); }
export function definitionsFailure(e, id, name) {
if (e instanceof CliError) return e;
const coded = (code, text, exit = 3) => Object.assign(new CliError(`${code}: ${text}`, exit), { code });
if (typeof e?.syscall === 'string' && typeof e.code === 'string' && /^E[A-Z0-9]+$/.test(e.code)) {
if (e.code === 'ENOENT') return coded('unknown-organization', `this instance has no organization ${id}; list them with mosaic org list`);
return coded('organization-unreadable', `cannot ${e.syscall} the file of organization ${id} (${e.code})`);
}
const message = typeof e?.message === 'string' ? e.message : '';
if (/^definitions declares no /.test(message)) return coded('definition-missing', missing(id, name));
if (typeof e?.code === 'string' && /^[a-z][a-z-]{0,63}$/.test(e.code)) {
const text = e.code === 'definition-missing' ? missing(id, name) : pathFree(message);
return coded(e.code, text);
}
if (e?.name === 'OrganizationConfigError' || e?.name === 'BusinessError') return coded('organization-invalid', pathFree(message));
return coded('definitions-failed', 'the definitions command failed', 1);
}
// deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs
// passes its callFor). Without one, a write refuses: it never runs without the bus's secret check.
export async function definitionsCommand(args, io, deps = {}) {
@@ -68,6 +92,10 @@ export async function definitionsCommand(args, io, deps = {}) {
const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); };
const [id, verb, name] = pos;
if (WRITES.includes(verb)) refuseInsideAgent(io.env);
if (id !== 'templates' && id !== undefined && !ID_PATTERN.test(id)) throw new CliError(`invalid organization id: an id is 1 to 64 of a-z, 0-9, '.', '_' and '-', starting with a letter or digit\n${DEFINITIONS_USAGE}`, 4);
try { await run(); }
catch (e) { throw definitionsFailure(e, id, name); }
async function run() {
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) });
let result;
@@ -93,4 +121,5 @@ export async function definitionsCommand(args, io, deps = {}) {
}
} else throw new CliError(DEFINITIONS_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
}
+21 -2
View File
@@ -100,6 +100,15 @@ export function autonomyDocument(current, changes) {
return out;
}
// E2E6 fix 7: the autonomy-precondition check the CLI can see from the read and the document it sent:
// owner notices off while a class sits near the loop, or moves away from the loop.
const ORDER = ['in the loop', 'near the loop', 'out of the loop'];
export function preconditionSeen(current, doc) {
if (doc?.notices === true) return undefined;
const moved = CLASSES.filter(c => ORDER.indexOf(doc?.[c]) > ORDER.indexOf(current?.levels?.[c] ?? 'in the loop'));
if (CLASSES.some(c => doc?.[c] === 'near the loop') || moved.length) return 'owner notices are off; a class near the loop, or one moving away from it, needs them on (--notices on)';
return undefined;
}
// Autonomy codes carry no message from the route; these are the panel's own words (autonomy.js).
const AUTONOMY_GUIDANCE = {
'revision-conflict': 'the organization changed; read it again with mosaic org autonomy ID',
@@ -117,6 +126,10 @@ function refused(answer, what, unknown, guidance = {}) {
const code = typeof raw === 'string' && /^[a-z-]{1,64}$/.test(raw) ? raw : `http ${answer.status}`;
if (code === 'outcome-unknown' || (answer.status >= 500 && !raw)) return new CliError(`outcome unknown: ${unknown}`, 1);
const said = typeof answer.body?.message === 'string' ? answer.body.message.slice(0, 500) : guidance[code];
// E2E6 fix 7: a route that forwards the bus's reason (which check failed) has it said first; the
// general guidance becomes the next line. A reason is one printable line, never a control byte.
const reason = typeof answer.body?.reason === 'string' && /^[\x20-\x7e]{1,300}$/.test(answer.body.reason) ? answer.body.reason : undefined;
if (reason) return new CliError(`${what} refused (${code}): ${reason}`, 3, { next: said });
return new CliError(`${what} refused (${code})${said ? `: ${said}` : ''}`, 3);
}
@@ -173,8 +186,14 @@ export async function orgOwnerCommand(args, io, deps = {}) {
const read = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
if (!ok(read)) throw refused(read, 'autonomy set', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
const unknown = `the setting may have been saved; check mosaic org autonomy ${id} before trying again`;
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy', body: { revision, autonomy: autonomyDocument(read.body, changes) }, unknown });
if (!ok(answer)) throw refused(answer, 'autonomy set', unknown, AUTONOMY_GUIDANCE);
const doc = autonomyDocument(read.body, changes);
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy', body: { revision, autonomy: doc }, unknown });
if (!ok(answer)) {
// E2E6 fix 7: a route that forwards no reason still gets the one check this side can see.
const local = answer.body?.error === 'autonomy-precondition' && answer.body?.reason === undefined ? preconditionSeen(read.body, doc) : undefined;
if (local) throw new CliError(`autonomy set refused (autonomy-precondition): ${local}`, 3, { next: AUTONOMY_GUIDANCE['autonomy-precondition'] });
throw refused(answer, 'autonomy set', unknown, AUTONOMY_GUIDANCE);
}
return { value: answer.body, text: b => `saved autonomy for ${id}: revision ${b.revision ?? 'unknown'}${b.transaction ? `, receipt ${b.transaction}` : ''}\n` };
};
} else if (step === 'review') {
+21 -14
View File
@@ -63,7 +63,22 @@ const PACK_REFUSALS = {
'invalid-request': 'the bus refused the request: a bad argument, a project that is not a code project, or a pack that fails the composition check',
'human-required': 'only the organization\'s owner, from a human shell, may do this',
'starter-pack-not-found': 'no installed, shipped or saved pack has that name',
'starter-pack-invalid': 'the pack fails its own checks or the composition check; nothing was written',
'starter-pack-template-changed': 'a template the pack names changed since it was loaded; preview again',
'not-configured': 'this organization\'s bus has no organization writer configured',
};
// E2E6 fix 3: humanTransport throws a CliError whose message and .code are the bus code alone. A code
// a table knows gains its hint, as template save's do; a next line it carried is kept. Any other
// CliError (bus-not-running with its own next line, a usage error) passes unchanged, and a bare coded
// error (a test's call) becomes a CliError named by its code.
function hinted(e, ...tables) {
const code = typeof e?.code === 'string' ? e.code : '';
const table = tables.find(t => Object.hasOwn(t, code));
if (!table && (code === '' || e instanceof CliError)) return e;
const error = new CliError(table ? `${code}: ${table[code]}` : code, busExit(code), { next: e.next });
error.code = code;
return error;
}
async function packsCommand(args, io, deps) {
const pos = [], values = {}, as = [];
for (let i = 0; i < args.length; i++) {
@@ -105,13 +120,7 @@ async function packsCommand(args, io, deps) {
}
let result;
try { result = await call(verb, request); }
catch (e) {
if (typeof e?.code !== 'string' || e.code === '' || e instanceof CliError) throw e;
const why = PACK_REFUSALS[e.code];
const error = new CliError(why ? `${e.code}: ${why}` : e.code, busExit(e.code));
error.code = e.code;
throw error;
}
catch (e) { throw hinted(e, PACK_REFUSALS); }
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
// #1873 SP8: template save previews (--preview) or writes the previewed JSON (--digest); template
@@ -120,7 +129,8 @@ async function packsCommand(args, io, deps) {
const TEMPLATE_REFUSALS = {
'template-secret-suspected': 'a field the template would keep holds a credential the organization\'s bus holds; remove it and preview again (nothing was written)',
'pack-plan-stale': 'the organization or a template changed since the preview; preview again and save its digest',
'invalid-request': 'the bus refused the request: a bad name, a name already saved, an unknown project, or an organization the template cannot hold',
'template-exists': 'a template of this name is already saved for your account; nothing was written',
'invalid-request': 'the bus refused the request: a bad name, an unknown project, or an organization the template cannot hold',
'human-required': 'only the organization\'s owner, from a human shell, may do this',
'not-configured': 'the templates directory is missing or not private to this instance\'s user',
'starter-pack-invalid': 'the template would not be a valid pack: more than 8 hires, or a definition wider than its template',
@@ -147,12 +157,9 @@ async function templateCommand(args, io, deps) {
let result;
try { result = await packCall(id, io, deps)(verb, request); }
catch (e) {
// humanTransport throws a CliError named by the bus code alone: a known code gains its hint.
const why = typeof e?.code !== 'string' ? undefined
: Object.hasOwn(TEMPLATE_REFUSALS, e.code) ? TEMPLATE_REFUSALS[e.code] : Object.hasOwn(PACK_REFUSALS, e.code) ? PACK_REFUSALS[e.code] : undefined;
if (!why) throw e;
const error = new CliError(`${e.code}: ${why}`, busExit(e.code));
error.code = e.code;
const error = hinted(e, TEMPLATE_REFUSALS, PACK_REFUSALS);
// E2E6 fix 8: a taken name says what to do next.
if (error.code === 'template-exists' && error.next === undefined) error.next = `preview and save under another name, or see the saved names with mosaic org template list ${id}`;
throw error;
}
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
+13
View File
@@ -41,6 +41,7 @@ const EXIT = {
"tester-required": 3,
"tester-off": 3,
"tester-refused": 3,
"bus-not-running": 3,
"outcome-unknown": 1,
"outcome-unknown-revoked": 1,
"response-too-large": 1,
@@ -51,6 +52,17 @@ export function busExit(code) {
return EXIT[code] ?? 2;
}
// E2E6 fix 6: the shim exits 5 (packages/bus/src/client.mjs NOT_SENT_EXIT) when its connect failed
// before the request was written: no socket, or the connect refused. That is a stopped bus, and
// nothing happened; outcome-unknown stays for a request that left and got no answer.
const NOT_SENT_EXIT = 5;
export function busNotRunning(business) {
const error = new CliError(`bus-not-running: the bus for ${business} is not running, so the request was not sent`, 3);
error.code = "bus-not-running";
error.next = `start it with mosaic bus start ${business}, or clear a host that died with mosaic bus recover ${business}`;
return error;
}
// Returns a call(verb, args) for one business. `cli` and `spawn` exist for
// the tests; the command line never sets them.
export function humanTransport({ socket, business, env = process.env, cli = HUMAN_CLI, spawn = spawnSync, timeoutMs = 30000 }) {
@@ -65,6 +77,7 @@ export function humanTransport({ socket, business, env = process.env, cli = HUMA
if (proc.error || proc.status === null) throw new CliError("outcome-unknown: the bus transport did not finish", 1);
if (proc.status !== 0) {
const code = String(proc.stderr ?? "").trim().split("\n").reverse().find((l) => /^[a-z-]{1,64}$/.test(l)) ?? "invalid-response";
if (proc.status === NOT_SENT_EXIT && code === "outcome-unknown") throw busNotRunning(business);
const error = new CliError(code, busExit(code));
error.code = code;
throw error;