#!/usr/bin/env bun /** * Standalone entry point — the only operator surface after #309. Everything here * is argument handling, the shared usage text, and the registration table that * dispatches each verb to its own module in ./commands/.ts; the per-verb * bodies moved there by the module split (#462), so a new operator subcommand * is one file plus one table line. The loop, the caps and the state live in * ./daemon.ts and the background process lifecycle in ./lifecycle.ts. */ import { userInfo } from "node:os"; import { loadConfig } from "./config.ts"; import { COMMAND_MANIFEST, renderUsage, type CommandManifestEntry } from "./command-manifest.ts"; import { armCommand } from "./commands/arm.ts"; import { boardCommand } from "./commands/board.ts"; import { companionCommand } from "./commands/companion.ts"; import { briefUpgradeCommand } from "./commands/brief-upgrade.ts"; import { consoleCommand } from "./commands/console.ts"; import { daemonCommand } from "./commands/daemon.ts"; import { dashboardCommand } from "./commands/dashboard.ts"; import { decisionCommand } from "./commands/decision.ts"; import { disarmCommand } from "./commands/disarm.ts"; import { doctorCommand } from "./commands/doctor.ts"; import { drainCommand } from "./commands/drain.ts"; import { epicCommand } from "./commands/epic.ts"; import { eventCommand } from "./commands/event.ts"; import { extendCommand } from "./commands/extend.ts"; import { frictionCommand } from "./commands/friction.ts"; import { helpCommand } from "./commands/help.ts"; import { holdCommand } from "./commands/hold.ts"; import { intakeCommand } from "./commands/intake.ts"; import { ledgerCommand } from "./commands/ledger.ts"; import { messageCommand } from "./commands/message.ts"; import { reportCommand } from "./commands/report.ts"; import { releaseCompositionCommand } from "./commands/release-composition.ts"; import { restartCommand } from "./commands/restart.ts"; import { restoreDbCommand } from "./commands/restore-db.ts"; import { snapshotDbCommand } from "./commands/snapshot-db.ts"; import { resumeCommand } from "./commands/resume.ts"; import { setupCommand } from "./commands/setup.ts"; import { startCommand } from "./commands/start.ts"; import { statsCommand } from "./commands/stats.ts"; import { statusCommand } from "./commands/status.ts"; import { stopCommand } from "./commands/stop.ts"; import { tailCommand } from "./commands/tail.ts"; import { unblockCommand } from "./commands/unblock.ts"; import { unfreezeCommand } from "./commands/unfreeze.ts"; import { upgradeInstallCommand } from "./commands/upgrade-install.ts"; import { reconcileUnitsCommand } from "./commands/reconcile-units.ts"; import { upgradeRollbackCommand } from "./commands/upgrade-rollback.ts"; import { upgradeCommand } from "./commands/upgrade.ts"; import { verbCommand } from "./commands/verb.ts"; import { versionCommand } from "./commands/version.ts"; import { watchCommand } from "./commands/watch.ts"; import { workerCommand } from "./commands/worker.ts"; import { COMMAND_SCOPES, resolveProjectsByScope, type CommandContext, type CommandHandler, } from "./commands/context.ts"; const USAGE = renderUsage(COMMAND_MANIFEST); /** * Verbs that define every `--help`/`-h` shape themselves, so the dispatch * gate below must not pre-empt them: `help` and the version aliases print * their own text, `setup` accepts help only as the first trailing token * (`setup bogus --help` must reject `bogus`, never print a help read), and * the hand-rolled usage verbs print help at `argv[1]` or refuse a trailing * unknown token with exit 2 — every one of them already before any side * effect. `intake` and `watch` are deliberately absent: they validate their * tails only after `loadConfig`/`openStore`, so an unconsumed trailing help * must be answered by the gate before the handler can create the store * (#863). */ const HELP_OWNED_VERBS: Record = { "--help": true, "-h": true, help: true, "--version": true, "-V": true, version: true, setup: true, dashboard: true, doctor: true, stats: true, drain: true, "restore-db": true, }; /** * `setup`'s install subcommands — the one `setup` shape the wizard does not * safely own: help is recognized only at the first trailing token, so * `setup host --help` would reach the privileged install planner (creating * the state db and staging the host install) instead of printing help. The * dispatch gate answers real help requests in their tail instead. */ const SETUP_INSTALL_SUBCOMMANDS: Record = { host: true, graph: true, }; /** * Whether an invocation carries an actual `--help`/`-h` request — a token the * verb's own grammar does not consume. A token immediately after a * value-taking flag is that flag's value (`report --text --help` sends the * literal text "--help", exactly as before), and a bare `--` makes everything * after it positional; only an unconsumed help token is really a request. */ function helpRequested(argv: readonly string[], entry: CommandManifestEntry): boolean { let consumeNext = false; for (const token of argv.slice(1)) { if (consumeNext) { consumeNext = false; continue; } if (token === "--") { // A bare `--` ends option parsing for a verb whose manifest actually // declares positionals: after it, `--help` is positional data, not a // request. A flag-only verb has no positional grammar for `--` to // protect, so `stop --all -- --help` (#863) must stay a help read. if ((entry.positionals?.length ?? 0) > 0) return false; continue; } if (token === "--help" || token === "-h") return true; // `--flag VALUE` consumes the next token as the flag's value, but // `--flag=VALUE` binds the value inline — so only the split form can // swallow a following `--help`. `stop --project=conductor --help` is a // help read (#863), and `--project conductor --help` must read the next // token as the value, exactly as `report --text --help` sends literal // text. A token without `=` is a separate argument: when it names a // value-taking flag, its value is the next token. consumeNext = token.startsWith("--") && !token.includes("=") && entry.flags.some((flag) => flag.name === token && flag.takesValue); } return false; } /** * One verb's usage block from the manifest — what `VERB --help` prints. The * manifest's `details` is that verb's long-form semantics, and rendering it * here is what keeps the generated read complete: the dispatch gate above * answers help for every verb that does not own it, so a verb whose prose * lived only in its own hand-rolled usage string would silently lose it * (#863 — `watch`'s no-answer/auto-withdraw semantics and `intake`'s * durability/groomed no-op semantics). */ function renderCommandHelp(entry: CommandManifestEntry): string { const lines = [`omp-conductor ${entry.name} — ${entry.description}`, "", "usage:"]; for (const usage of entry.usage) lines.push(` omp-conductor ${usage}`); if (entry.flags.length > 0) { lines.push("", "flags:"); const width = Math.max(...entry.flags.map((flag) => flag.name.length)); for (const flag of entry.flags) lines.push(` ${flag.name.padEnd(width)} ${flag.description}`); } if (entry.details !== undefined) lines.push("", entry.details); return lines.join("\n"); } /** Applies both `--port 9000` and `--port=9000`; returns undefined when absent. */ function flag(argv: string[], name: string): string | undefined { const i = argv.indexOf(`--${name}`); if (i >= 0) return argv[i + 1]; const prefixed = argv.find((a) => a.startsWith(`--${name}=`)); return prefixed?.slice(name.length + 3); } function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] { const raw = flag(argv, name); if (raw === undefined) { if (argv.includes(`--${name}`)) { process.stderr.write(`omp-conductor: report --${name} needs comma-separated row ids\n`); process.exit(2); } return []; } const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))]; // The three shapes the runtime actually mints as ledger row ids: 12-hex // material event ids, UUID held-notice ids (quiet-hours material holds, // operator messages), and 64-hex sha256 held-notice ids (escalate and the // orchestrator tick). Anything else is a typo; refusing it is what keeps a // mistyped id from silently claiming nothing. if ( ids.length === 0 || ids.some( (id) => !/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{64})$/.test( id, ), ) ) { process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`); process.exit(2); } return ids; } /** * `--port` for the three verbs that take one. Exits 2 rather than defaulting, * because silently ignoring a typo'd port would leave the operator probing an * endpoint the daemon is not on. */ function portFlag(argv: string[]): number | undefined { const raw = flag(argv, "port"); const port = raw === undefined ? undefined : Number.parseInt(raw, 10); const valueless = raw === undefined && argv.includes("--port"); if (valueless || (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))) { process.stderr.write(`omp-conductor: --port needs a port number, got "${raw ?? ""}"\n`); process.exit(2); } return port; } /** Required positive integer for `extend`; no partial parses such as `180x`. */ function turnsFlag(argv: string[]): number { const raw = flag(argv, "turns"); const turns = raw === undefined ? Number.NaN : Number(raw); if (!Number.isSafeInteger(turns) || turns < 1) { process.stderr.write(`omp-conductor: extend needs --turns with a positive integer, got "${raw ?? ""}"\n`); process.exit(2); } return turns; } function humanDuration(ms: number): string { const s = Math.max(0, Math.round(ms / 1000)); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ${String(m % 60).padStart(2, "0")}m`; return `${Math.floor(h / 24)}d ${String(h % 24).padStart(2, "0")}h`; } /** * The `` positional, for the two verbs that take one. Exits 2 rather * than following run #NaN or clearing the labels of issue #0; `verb` is named * in the message so the operator is told which of the two they mistyped. */ function issueArg(verb: string, raw: string | undefined): number { const issue = raw === undefined ? Number.NaN : Number.parseInt(raw.replace(/^#/, ""), 10); if (!Number.isInteger(issue) || issue < 1) { process.stderr.write(`omp-conductor: ${verb} needs an issue number, got "${raw ?? ""}"\n`); process.exit(2); } return issue; } /** * Removed verbs keep an entry rather than falling through to usage: an operator * who typed the old word gets the new one, not a wall of help text. Exit 2 is * the same code an unknown verb uses. */ const REMOVED: Record = { halt: '"halt" is now "stop" — see help', pause: '"pause" is gone — "hold" pauses claiming AND disarms ticks; see help', "release-pane": '"release-pane" is now part of "resume" — see help', "graph-setup": '"graph-setup" is now "setup graph" — see help', }; /** The registration table used by dispatch and the manifest drift test. */ export function commandHandlers(ctx: CommandContext): Record { return { "--version": () => versionCommand(), "-V": () => versionCommand(), version: () => versionCommand(), complete: async () => (await import("./commands/complete.ts")).completeCommand(ctx.argv.slice(1)), setup: () => setupCommand(ctx), upgrade: () => upgradeCommand(ctx), "upgrade-install": () => upgradeInstallCommand(ctx), "reconcile-units": () => reconcileUnitsCommand(ctx), "upgrade-rollback": () => upgradeRollbackCommand(ctx), daemon: () => daemonCommand(ctx), start: () => startCommand(ctx), stop: () => stopCommand(ctx), restart: () => restartCommand(ctx), status: () => statusCommand(ctx), stats: () => statsCommand(ctx), doctor: () => doctorCommand(ctx), ledger: () => ledgerCommand(ctx), board: () => boardCommand(ctx), companion: () => companionCommand(ctx), console: () => consoleCommand(ctx), dashboard: () => dashboardCommand(ctx), hold: () => holdCommand(ctx), drain: () => drainCommand(ctx), arm: () => armCommand(ctx), disarm: () => disarmCommand(ctx), tail: () => tailCommand(ctx), extend: () => extendCommand(ctx), worker: () => workerCommand(ctx), unblock: () => unblockCommand(ctx), "release-composition": () => releaseCompositionCommand(ctx), verb: () => verbCommand(ctx), unfreeze: () => unfreezeCommand(ctx), event: () => eventCommand(ctx), report: () => reportCommand(ctx), message: () => messageCommand(ctx), decision: () => decisionCommand(ctx), watch: () => watchCommand(ctx), intake: () => intakeCommand(ctx), epic: () => epicCommand(ctx), friction: () => frictionCommand(ctx), "restore-db": () => restoreDbCommand(ctx), "snapshot-db": () => snapshotDbCommand(ctx), resume: () => resumeCommand(ctx), "brief-upgrade": () => briefUpgradeCommand(ctx), help: () => helpCommand(USAGE), "--help": () => helpCommand(USAGE), "-h": () => helpCommand(USAGE), }; } export async function runCli(argv: string[] = process.argv.slice(2)): Promise { const cmd = argv[0]; // Completion runs on every TAB press. Answer before config, daemon, or // project-context construction so a shell request stays cheap and fail-empty. if (cmd === "complete") { try { const requestDelimiter = argv.indexOf("--", 1); const ownArgs = argv.slice(1, requestDelimiter === -1 ? undefined : requestDelimiter); const ownProjectFlag = flag(ownArgs, "project"); if (COMMAND_SCOPES[cmd] === "none" && ownProjectFlag !== undefined) { process.stderr.write( `omp-conductor: ${cmd} does not take a project — it needs no --project\n`, ); process.exit(2); } (await import("./commands/complete.ts")).completeCommand(argv.slice(1)); return; } catch (err) { process.stderr.write(`omp-conductor: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); } } const projectFlag = flag(argv, "project"); const ctx: CommandContext = { argv, projectFlag, flag: (name) => flag(argv, name), portFlag: () => portFlag(argv), turnsFlag: () => turnsFlag(argv), digestIdsFlag: (name) => digestIdsFlag(argv, name), issueArg: (verb, raw) => issueArg(verb, raw), targetProjects: () => resolveProjectsByScope(loadConfig(), "fleet", projectFlag, argv.includes("--all")), }; const commands = commandHandlers(ctx); try { const removed = cmd === undefined ? undefined : REMOVED[cmd]; if (removed !== undefined) { process.stderr.write(`omp-conductor: ${removed}\n`); process.exit(2); } const handler = cmd === undefined ? undefined : commands[cmd]; if (handler === undefined) { process.stderr.write( `${cmd === undefined ? "omp-conductor: no subcommand" : `omp-conductor: unknown subcommand "${cmd}"`}\n\n${USAGE}\n`, ); process.exit(2); } // A real `--help`/`-h` (one no declared flag consumes) is a help read and // must never reach a mutating command: `stop --all --help` is how #863 // disarmed the fleet. Recognized here — before project-selection // (`targetProjects` is a lazy closure; no config is loaded) and before // handler execution — so a verb that does not validate its own tail can // no longer treat the request as an argument. const entry = cmd === undefined ? undefined : COMMAND_MANIFEST.find((entry) => entry.name === cmd); let help = false; if (entry !== undefined) { if (cmd === "setup") { // `setup` owns its help (only at the first trailing token, which is // what keeps `setup bogus --help` rejecting `bogus`). The install // subcommands are the exception: a help token in their tail must be // answered here, before `loadConfig` and the install plan (#863). help = SETUP_INSTALL_SUBCOMMANDS[argv[1] ?? ""] === true && helpRequested(argv, entry); } else if (HELP_OWNED_VERBS[cmd ?? ""] !== true) { help = helpRequested(argv, entry); } } if (help && entry !== undefined) { process.stdout.write(`${renderCommandHelp(entry)}\n`); return; } // A "none"-scope verb does no per-project work, so --project cannot change // what it does: refuse it with a reason instead of silently ignoring it. if (COMMAND_SCOPES[cmd ?? ""] === "none" && projectFlag !== undefined) { process.stderr.write(`omp-conductor: ${cmd} does not take a project — it needs no --project\n`); process.exit(2); } await handler(ctx); } catch (err) { process.stderr.write(`omp-conductor: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); } } if (import.meta.main) await runCli();