The discord CLI's top-level handler prints a native Node error that is not already a labelled refusal as 'cannot <syscall> <label> (<CODE>)'. The journal and approvals readers and writers name the file by label at the source, so conversation, control-board and webui callers get the label too. The session directory check moves to engine-pi.mjs as sessionContinues(). Exit codes and refusal codes are unchanged.
414 lines
21 KiB
JavaScript
Executable File
414 lines
21 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Usage:
|
|
// mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]
|
|
// mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]
|
|
// mosaic-discord stop <binding> [--config PATH]
|
|
// mosaic-discord unlock <binding> [--config PATH]
|
|
// mosaic-discord recover <binding> [--config PATH]
|
|
// mosaic-discord reload <binding> [--config PATH]
|
|
//
|
|
// <binding> names <dataRoot>/discord/<binding>.json. The repository wrapper
|
|
// is scripts/discord.sh.
|
|
//
|
|
// check: binding, token file, context files and pi are validated; then the
|
|
// bot identity, guild and every listed channel are read over REST; then one
|
|
// gateway connection is made and closed after READY. Nothing is sent to a
|
|
// channel. A close code 4014 means the message-content intent is not
|
|
// granted in the developer portal.
|
|
// run: refuses when STOP exists or the outbox cannot be reconciled; otherwise
|
|
// starts the engine and the gateway and serves turns until SIGTERM, SIGINT
|
|
// or `stop`.
|
|
// stop: writes STOP and sends SIGTERM to the owner in run.lock, only when that
|
|
// process is alive and its start time and boot id match the record.
|
|
// unlock: writes STOP, then removes a run.lock whose owner is gone (crash,
|
|
// reboot, interrupted start). Refuses while the owner is live (use stop), alive
|
|
// with unverifiable identity, or recorded in a file it cannot read. `run` never reclaims on its own, and a
|
|
// claim that finds STOP after publishing releases itself, so unlock cannot
|
|
// race a start. Remove STOP to run again.
|
|
// recover: the supervised pre-start. Refuses while STOP is present or the
|
|
// binding is held by a live or unverifiable process; clears a lock whose
|
|
// owner is gone the way unlock does, then removes the STOP it wrote for
|
|
// that, so the run that follows can claim. Never removes a STOP an operator
|
|
// wrote. `run --supervised` does the same first thing itself; the service
|
|
// unit (scripts/discord-service.sh) uses that form, because systemd only
|
|
// honours a never-retry exit status from the main process, not from a
|
|
// pre-start command.
|
|
//
|
|
// reload: validates the binding file, then sends SIGHUP to the live owner in
|
|
// run.lock. The running process re-reads the file and applies guildName,
|
|
// channels, users and limits in place; a new channel is read over REST and
|
|
// must be in the bound guild. Any fixed key changed, an invalid file or a
|
|
// failed lookup refuses the reload and keeps the old binding. Each attempt is
|
|
// one line in reloads.jsonl. The service unit maps `systemctl --user reload`
|
|
// to the same signal.
|
|
//
|
|
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or configuration;
|
|
// 3 refused by a brake (STOP present or the binding held; a supervisor must
|
|
// not retry); 4 usage.
|
|
|
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { DiscordError, printable } from "./errors.mjs";
|
|
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
|
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
|
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
|
|
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
|
|
import { createRest } from "./rest.mjs";
|
|
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
|
import { createEngine, buildPiArgs, sessionContinues } from "./engine-pi.mjs";
|
|
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
|
|
import { createSetsparkApi } from "./setspark.mjs";
|
|
import { assembleContext } from "./context.mjs";
|
|
import { createConnector } from "./connector.mjs";
|
|
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, journalLabel, BRAKE_EXIT } from "./journal.mjs";
|
|
|
|
const USAGE = [
|
|
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
|
|
" mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]",
|
|
" mosaic-discord stop <binding> [--config PATH]",
|
|
" mosaic-discord unlock <binding> [--config PATH]",
|
|
" mosaic-discord recover <binding> [--config PATH]",
|
|
" mosaic-discord reload <binding> [--config PATH]",
|
|
].join("\n");
|
|
|
|
function parse(argv) {
|
|
const opts = { command: null, binding: null, config: defaultConfigPath(), repo: process.cwd(), supervised: false, offline: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--config" || a === "--repo") {
|
|
if (i + 1 >= argv.length) throw new DiscordError(`missing value for ${a}`, 4);
|
|
opts[a.slice(2)] = resolve(argv[++i]);
|
|
} else if (a === "--help" || a === "-h") {
|
|
opts.command = "help";
|
|
} else if (a === "--supervised") {
|
|
opts.supervised = true;
|
|
} else if (a === "--offline") {
|
|
opts.offline = true;
|
|
} else if (a.startsWith("--")) throw new DiscordError(`unknown argument: ${a}\n${USAGE}`, 4);
|
|
else if (opts.command === null) opts.command = a;
|
|
else if (opts.binding === null) opts.binding = a;
|
|
else throw new DiscordError(`unexpected argument: ${a}\n${USAGE}`, 4);
|
|
}
|
|
if (opts.command === "help") return opts;
|
|
if (!["check", "run", "stop", "unlock", "recover", "reload"].includes(opts.command)) throw new DiscordError(USAGE, 4);
|
|
if (opts.binding === null) throw new DiscordError(`${opts.command} needs a binding name\n${USAGE}`, 4);
|
|
if (opts.supervised && opts.command !== "run") throw new DiscordError(`--supervised applies to run only\n${USAGE}`, 4);
|
|
if (opts.offline && opts.command !== "check") throw new DiscordError(`--offline applies to check only\n${USAGE}`, 4);
|
|
return opts;
|
|
}
|
|
|
|
function say(msg) {
|
|
process.stdout.write(`${msg}\n`);
|
|
}
|
|
function warn(msg) {
|
|
process.stderr.write(`discord: ${msg}\n`);
|
|
}
|
|
|
|
// Case 2 M8 (design 5.1, row 220): a seat that works in a library business runs
|
|
// only on the mosaic-bus engine, with no tools key. The business files come from
|
|
// the config directory --config names. An unreadable file refuses; it never reads
|
|
// as "no library seats".
|
|
function checkLibraryEngine(configDirectory, binding) {
|
|
let sets;
|
|
try {
|
|
sets = loadSeatSets({ dir: configDirectory });
|
|
} catch (err) {
|
|
throw new DiscordError(`discord-business-unreadable: ${printable(err, "a business file")}`, 2);
|
|
}
|
|
if (!sets.librarySeats.has(binding.seat)) return;
|
|
if (binding.engine.provider !== "mosaic-bus") {
|
|
throw new DiscordError(`discord-engine-not-allowed: binding ${binding.name} runs seat ${binding.seat}, which works in a library business; its engine must be mosaic-bus, not ${binding.engine.provider}`, 2);
|
|
}
|
|
if (binding.tools !== null) {
|
|
throw new DiscordError(`discord-engine-not-allowed: binding ${binding.name} runs seat ${binding.seat}, which works in a library business; a mosaic-bus binding there may not declare the tools key`, 2);
|
|
}
|
|
}
|
|
|
|
// Everything that can be checked without the network, shared by check and run.
|
|
function prepare(opts) {
|
|
const dataRoot = loadDataRoot(opts.config);
|
|
const bindingFile = bindingPath(dataRoot, opts.binding);
|
|
const binding = loadBinding(bindingFile);
|
|
if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`);
|
|
checkLibraryEngine(dirname(opts.config), binding);
|
|
const contextFiles = resolveContextFiles(binding, opts.repo);
|
|
const toolRoots = resolveToolRoots(binding, { dataRoot });
|
|
const pi = join(opts.repo, "node_modules", ".bin", "pi");
|
|
if (!existsSync(pi)) throw new DiscordError("pi not found at node_modules/.bin/pi in the repository; run npm ci in the repository");
|
|
const journalDir = bindingDataDir(dataRoot, binding.name);
|
|
const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`);
|
|
return { dataRoot, bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir };
|
|
}
|
|
|
|
async function check(opts) {
|
|
const { binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
|
|
const token = readToken(binding);
|
|
say(`binding ${binding.name}: seat ${binding.seat}, guild ${binding.guildId} (${binding.guildName}), ${binding.channels.length} channel(s), ${binding.users.length} user(s)`);
|
|
say(`engine ${binding.engine.provider}/${binding.engine.model}:${binding.engine.thinking}, limits ${JSON.stringify(binding.limits)}`);
|
|
say(`context ${contextFiles.length} file(s); pi ${pi}; journal ${journalDir}; session ${sessionDir}`);
|
|
say(toolRoots ? `tools: ${enabledToolNames(toolRoots).join(",")}; roots ${toolRoots.roots.map((r) => `${r.name}=${r.path}${r.write ? " (writable)" : ""}`).join(" ")}, ${toolRoots.maxCallsPerTurn} calls/message, ${toolRoots.maxFileBytes} bytes/file${toolRoots.web ? `, web via ${toolRoots.web.searxng} (${toolRoots.web.maxFetchBytes} bytes/page)` : ""}${toolRoots.setspark ? `, setspark via ${toolRoots.setspark.baseUrl} as ${toolRoots.setspark.principal}` : ""}` : "tools: none");
|
|
say(`token file mode 0600 ok; STOP ${stopRequested(journalDir) ? "PRESENT" : "absent"}`);
|
|
|
|
if (opts.offline) {
|
|
say("offline check passed; no REST or gateway calls were made");
|
|
return;
|
|
}
|
|
|
|
const rest = createRest({ token, log: warn });
|
|
const me = await rest.getMe();
|
|
if (me.id !== binding.botUserId) throw new DiscordError(`token belongs to bot ${me.id}, binding says ${binding.botUserId}`);
|
|
say(`rest: bot ${me.id} (${me.username}) matches binding`);
|
|
const guild = await rest.getGuild(binding.guildId);
|
|
say(`rest: guild ${guild.id} "${guild.name}" visible`);
|
|
for (const c of binding.channels) {
|
|
const ch = await rest.getChannel(c.id);
|
|
if (ch.guild_id !== binding.guildId) throw new DiscordError(`channel ${c.id} is in guild ${ch.guild_id}, not ${binding.guildId}`);
|
|
say(`rest: channel ${c.id} "#${ch.name}" type ${ch.type} (${c.mode}) visible`);
|
|
}
|
|
const gw = await rest.getGatewayBot();
|
|
say(`rest: gateway ${gw.url}, sessions remaining today ${gw.session_start_limit ? gw.session_start_limit.remaining : "?"}`);
|
|
|
|
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
|
|
gateway.on("log", (m) => warn(`gateway: ${m}`));
|
|
const outcome = await new Promise((resolveOutcome) => {
|
|
const timer = setTimeout(() => resolveOutcome({ error: "no READY within 30 s" }), 30000);
|
|
gateway.on("ready", (r) => {
|
|
clearTimeout(timer);
|
|
resolveOutcome({ ready: r });
|
|
});
|
|
gateway.on("fatal", (f) => {
|
|
clearTimeout(timer);
|
|
resolveOutcome({ fatal: f });
|
|
});
|
|
gateway.connect();
|
|
});
|
|
gateway.close(1000, "check complete");
|
|
if (outcome.fatal) throw new DiscordError(`gateway close ${outcome.fatal.code}: ${outcome.fatal.reason}`);
|
|
if (outcome.error) throw new DiscordError(`gateway: ${outcome.error}`, 1);
|
|
const { ready } = outcome;
|
|
if (!ready.user || ready.user.id !== binding.botUserId) throw new DiscordError(`gateway READY user ${ready.user && ready.user.id} does not match binding`);
|
|
const inGuild = ready.guilds.some((g) => g.id === binding.guildId);
|
|
if (!inGuild) throw new DiscordError(`gateway READY does not list guild ${binding.guildId}; is the bot in the server?`);
|
|
say(`gateway: READY as ${ready.user.id}, intents ${CONNECTOR_INTENTS} accepted (message content granted), guild listed`);
|
|
say("check passed; nothing was sent");
|
|
}
|
|
|
|
async function run(opts) {
|
|
const root = loadDataRoot(opts.config);
|
|
const generation = assertProcessing(root).generation;
|
|
const processing = () => assertProcessing(root, generation);
|
|
const { bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
|
|
const token = readToken(binding);
|
|
ensureJournal(journalDir);
|
|
if (opts.supervised) {
|
|
const outcome = recover(journalDir);
|
|
if (outcome === "cleared") warn("supervised start: run.lock left by a process that is gone was removed");
|
|
}
|
|
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, BRAKE_EXIT);
|
|
writePid(journalDir, process.pid);
|
|
let managed = null;
|
|
const cleanupPid = () => {
|
|
clearPid(journalDir, process.pid);
|
|
if (!managed) return;
|
|
if (processingStatus(root).halted) updateRuntime(root, managed.id, { phase: 'ended' });
|
|
else unregisterRuntime(root, managed.id);
|
|
};
|
|
try {
|
|
processing();
|
|
managed = registerRuntime(root, { kind: 'discord', business: 'instance', run: binding.name });
|
|
updateRuntime(root, managed.id, { ...processIdentity(process.pid), phase: 'running', group: false });
|
|
processing();
|
|
await runClaimed();
|
|
} catch (err) {
|
|
cleanupPid();
|
|
throw err;
|
|
}
|
|
|
|
async function runClaimed() {
|
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
const launches = join(journalDir, "launches");
|
|
mkdirSync(launches, { recursive: true, mode: 0o700 });
|
|
const launch = mkdtempSync(join(launches, "launch."));
|
|
const snapshot = assembleContext(contextFiles, binding);
|
|
const promptFile = join(launch, "context.md");
|
|
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
|
|
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
|
|
const continueSession = sessionContinues(sessionDir);
|
|
|
|
const rest = createRest({ token, log: warn });
|
|
const gw = await rest.getGatewayBot();
|
|
processing();
|
|
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
|
|
gateway.on("log", (m) => warn(`gateway: ${m}`));
|
|
gateway.on("ready", (r) => warn(`gateway: READY as ${r.user && r.user.id}, session ${r.sessionId}`));
|
|
gateway.on("resumed", () => warn("gateway: RESUMED"));
|
|
gateway.on("closed", (c) => warn(`gateway: closed ${c.code} ${c.reason} reconnect=${c.willReconnect}`));
|
|
|
|
const engine = createEngine({
|
|
processingRoot: root, binding: binding.name,
|
|
command: pi,
|
|
args: buildPiArgs({ ...binding.engine, sessionDir, appendSystemPromptFile: promptFile, continueSession, tools: toolRoots }),
|
|
cwd: opts.repo,
|
|
env: { ...process.env, MOSAIC_AGENT_NAME: binding.seat, ...(toolRoots ? { [TOOLS_ENV]: JSON.stringify(toolRoots) } : {}) },
|
|
log: warn,
|
|
onExit: (e) => {
|
|
warn(`engine exited: ${JSON.stringify(e)}; stopping`);
|
|
shutdown(1);
|
|
},
|
|
});
|
|
|
|
// The connector's own SetSpark client (bind and approvals) uses the same
|
|
// key as the model's verbs; without a setspark key it has none, and an
|
|
// approval request from the model is refused.
|
|
const api = binding.tools && binding.tools.setspark ? createSetsparkApi(binding.tools.setspark) : null;
|
|
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, processing, log: warn });
|
|
let shuttingDown = false;
|
|
let exitCode = 0;
|
|
let processingWatch = null;
|
|
const shutdown = (code, immediate = false) => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
clearInterval(processingWatch);
|
|
exitCode = code;
|
|
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
|
|
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${printable(err)}`)).finally(() => {
|
|
cleanupPid();
|
|
process.exit(exitCode);
|
|
});
|
|
};
|
|
gateway.on("fatal", (f) => {
|
|
warn(`gateway fatal close ${f.code}: ${f.reason}`);
|
|
shutdown(2);
|
|
});
|
|
process.on("SIGTERM", () => shutdown(0, processingStatus(root).halted === true));
|
|
process.on("SIGINT", () => shutdown(0));
|
|
processingWatch = setInterval(() => {
|
|
try { processing(); }
|
|
catch {
|
|
const state = processingStatus(root);
|
|
if (state.halted && !state.invalid) {
|
|
try { requestProcessingStop(journalDir, state.generation); }
|
|
catch { warn('processing STOP could not be recorded'); }
|
|
}
|
|
shutdown(BRAKE_EXIT, true);
|
|
}
|
|
}, 100);
|
|
processingWatch.unref();
|
|
|
|
// Reload on SIGHUP: re-read the file, refuse anything that is not a
|
|
// reloadable difference, verify new channels over REST, then swap.
|
|
// Attempts are serialized; the outcome is journaled and logged, and a
|
|
// refusal leaves the binding as it was.
|
|
const reloadNow = async () => {
|
|
const at = new Date().toISOString();
|
|
try {
|
|
processing();
|
|
const next = loadBinding(bindingFile);
|
|
if (next.name !== binding.name) throw new DiscordError(`reload: name ${JSON.stringify(next.name)} does not match ${binding.name}`);
|
|
const diff = reloadDiff(connector.binding, next);
|
|
for (const id of diff.channels.added) {
|
|
const ch = await rest.getChannel(id);
|
|
processing();
|
|
if (ch.guild_id !== next.guildId) throw new DiscordError(`reload: channel ${id} is in guild ${ch.guild_id}, not ${next.guildId}`);
|
|
}
|
|
connector.reload(next);
|
|
appendReload(journalDir, { at, outcome: "applied", ...diff });
|
|
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
|
|
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
|
|
} catch (err) {
|
|
appendReload(journalDir, { at, outcome: "refused", error: printable(err) });
|
|
warn(`reload refused, binding unchanged: ${printable(err)}`);
|
|
}
|
|
};
|
|
let reloadChain = Promise.resolve();
|
|
process.on("SIGHUP", () => {
|
|
reloadChain = reloadChain.then(reloadNow, reloadNow);
|
|
});
|
|
|
|
const started = await connector.start();
|
|
say(`run ${binding.name}: pid ${process.pid}, ${started.inbox} inbox id(s), ${started.reconciled.length} reconciled, context sha256 ${snapshot.sha256}, session ${continueSession ? "continued" : "new"}`);
|
|
say(`stop with: scripts/discord.sh stop ${binding.name}`);
|
|
}
|
|
}
|
|
|
|
|
|
function stop(opts) {
|
|
const dataRoot = loadDataRoot(opts.config);
|
|
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
|
const journalDir = bindingDataDir(dataRoot, binding.name);
|
|
ensureJournal(journalDir);
|
|
const path = requestStop(journalDir, "cli stop");
|
|
say(`STOP written: ${path}`);
|
|
const pid = stopTarget(journalDir);
|
|
if (pid !== null) {
|
|
process.kill(pid, "SIGTERM");
|
|
say(`SIGTERM sent to pid ${pid}; the current turn finishes or times out, then it exits`);
|
|
} else if (readPid(journalDir) !== null) {
|
|
say("run.lock exists but its process is gone, is a different process, or cannot be verified; nothing signaled. STOP stays in place; `unlock` clears a lock whose owner is gone");
|
|
} else {
|
|
say("no running connector found for this binding");
|
|
}
|
|
}
|
|
|
|
function unlockCommand(opts) {
|
|
const dataRoot = loadDataRoot(opts.config);
|
|
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
|
const journalDir = bindingDataDir(dataRoot, binding.name);
|
|
ensureJournal(journalDir);
|
|
const cleared = unlock(journalDir);
|
|
say(`STOP written: ${join(journalDir, "STOP")}`);
|
|
if (cleared === false) say("no run.lock for this binding");
|
|
else if (cleared === null) say("run.lock removed; it had no owner record (interrupted start)");
|
|
else say(`run.lock removed; its owner pid ${cleared.pid} is gone`);
|
|
say("remove STOP to run again");
|
|
}
|
|
|
|
function reloadCommand(opts) {
|
|
const dataRoot = loadDataRoot(opts.config);
|
|
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
|
if (binding.name !== opts.binding) throw new DiscordError(`binding name ${JSON.stringify(binding.name)} does not match file name ${opts.binding}`);
|
|
const journalDir = bindingDataDir(dataRoot, binding.name);
|
|
ensureJournal(journalDir);
|
|
const pid = stopTarget(journalDir);
|
|
if (pid === null) throw new DiscordError("no running connector for this binding; the file is valid and applies at the next start", 1);
|
|
process.kill(pid, "SIGHUP");
|
|
say(`SIGHUP sent to pid ${pid}; the outcome is the last line of ${join(journalDir, "reloads.jsonl")} and in its log`);
|
|
}
|
|
|
|
function recoverCommand(opts) {
|
|
const dataRoot = loadDataRoot(opts.config);
|
|
assertProcessing(dataRoot);
|
|
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
|
const journalDir = bindingDataDir(dataRoot, binding.name);
|
|
ensureJournal(journalDir);
|
|
const outcome = recover(journalDir);
|
|
if (outcome === "cleared") say("run.lock left by a process that is gone was removed; STOP is absent; ready to run");
|
|
else say("no lock and no STOP; ready to run");
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parse(process.argv.slice(2));
|
|
if (opts.command === "help") {
|
|
say(USAGE);
|
|
return 0;
|
|
}
|
|
if (opts.command === "check") await check(opts);
|
|
else if (opts.command === "run") await run(opts);
|
|
else if (opts.command === "unlock") unlockCommand(opts);
|
|
else if (opts.command === "recover") recoverCommand(opts);
|
|
else if (opts.command === "reload") reloadCommand(opts);
|
|
else stop(opts);
|
|
return opts.command === "run" ? null : 0;
|
|
}
|
|
|
|
main().then((code) => {
|
|
if (code !== null) process.exit(code);
|
|
}).catch((err) => {
|
|
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
|
|
// Never a native error's message: it carries the path (#1873).
|
|
warn(printable(err));
|
|
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
|
|
process.exit(code);
|
|
});
|