import { findProject } from "../config.ts"; import type { ConductorConfig, ProjectConfig } from "../types.ts"; /** * What a command handler may read about the invocation it was dispatched for. * * Argument parsing stays in cli.ts — the flag helpers, the shared usage text * and the registration table — and each handler receives the parsed essentials * plus the lookups it needs through this object. A command module therefore * never imports the entry point back: no import cycle, and no module-level * mutable state for one verb to change under another. */ export interface CommandContext { /** The full argv including the verb at index 0, read once per process. */ argv: string[]; /** * The `--project` value, parsed once because nearly every verb takes it. * Named `projectFlag` rather than `project` because many handlers bind their * own resolved `ProjectConfig` under that name. */ projectFlag: string | undefined; /** Applies both `--flag VALUE` and `--flag=VALUE`; undefined when absent. */ flag(name: string): string | undefined; /** * `--port` for the daemon-bound verbs. Exits 2 rather than defaulting, * because silently ignoring a typo'd port would leave the operator probing * an endpoint the daemon is not on. */ portFlag(): number | undefined; /** Required positive integer for `extend`; no partial parses such as `180x`. */ turnsFlag(): number; /** * `report`'s comma-separated `--events` / `--notices` ledger row ids, in the * three shapes the runtime actually mints. Exits 2 on anything else. */ digestIdsFlag(name: "events" | "notices"): string[]; /** * The `` positional, for the verbs that take one. Exits 2 rather * than proceeding with run #NaN; `verb` is named in the message. */ issueArg(verb: string, raw: string | undefined): number; /** * The projects a fleet-level verb targets: `--all`, `--project NAME`, or the * sole configured project (throwing when the choice would be ambiguous). */ targetProjects(): ProjectConfig[]; } /** One operator subcommand: what the registration table maps a verb to. */ export type CommandHandler = (ctx: CommandContext) => Promise | void; /** * The project scope a command's work is bound to, declared once per verb * (#514). Every command in `omp/src/commands/` carries one, and the * resolution a command performs must follow it rather than defaulting to * `findProject`: * * - `"project"` — the verb acts on exactly one configured project. It * resolves through {@link findProject}, so a multi-project config demands * `--project NAME` (ledger, stats, event, report, decision, …). * - `"host"` — the verb acts on the one shared host (units, daemon, package * surfaces), which no configured project can make ambiguous. It never * resolves a project; `--project` is either refused (upgrade/restart * refuse to narrow the shared multi-project daemon, #389) or accepted only * for the documented per-project tail it affects (start's /healthz check, * the `daemon --once` drill). * - `"fleet"` — the verb acts on every configured project by default and * narrows with `--project NAME` / `--all`, the `targetProjects` shape * (arm, disarm, hold, resume, stop, status, board). * - `"none"` — the verb takes no project at all (dashboard, help, version). */ export type CommandScope = "project" | "host" | "fleet" | "none"; /** * The explicit scope of every registered command, keyed by the exact strings * the CLI accepts (aliases included). `cli.test.ts` asserts each module in * `commands/` is classified and that each scope resolves the way the table * below says; REFERENCE.md's CLI table carries the same classification in * human form. */ export const COMMAND_SCOPES: Readonly> = { // project — exactly one project; findProject demands --project when several "brief-upgrade": "project", companion: "project", console: "project", decision: "project", drain: "project", epic: "project", event: "project", extend: "project", friction: "project", intake: "project", ledger: "project", message: "project", "release-composition": "project", report: "project", setup: "project", // the wizard and `setup graph` configure one project stats: "project", tail: "project", unblock: "project", unfreeze: "project", verb: "project", watch: "project", worker: "project", // host — the shared daemon/package surfaces; never resolves a project daemon: "host", restart: "host", "restore-db": "host", "snapshot-db": "host", start: "host", upgrade: "host", "upgrade-install": "host", "upgrade-rollback": "host", "reconcile-units": "host", // fleet — every project by default, --project narrows arm: "fleet", board: "fleet", disarm: "fleet", doctor: "fleet", hold: "fleet", resume: "fleet", status: "fleet", stop: "fleet", // none — no project at all complete: "none", dashboard: "none", help: "none", "--help": "none", "-h": "none", version: "none", "--version": "none", "-V": "none", }; /** * Resolve the projects one command's work covers, from its declared scope * (#514) — the single mechanism every scope falls out of, so a host-scoped * command can never inherit `findProject`'s ambiguity by accident. * * `"host"`/`"none"` resolve to nothing. `"fleet"` returns every configured * project with `all`, the named one, or — with neither — the sole configured * project, refusing a multi-project config exactly as `targetProjects` always * has. `"project"` returns exactly one, via {@link findProject}. */ export function resolveProjectsByScope( cfg: ConductorConfig, scope: CommandScope, name: string | undefined, all: boolean, ): ProjectConfig[] { if (scope === "host" || scope === "none") return []; if (scope === "fleet") { if (all) return [...cfg.projects]; if (name === undefined && cfg.projects.length > 1) { throw new Error( `config has ${cfg.projects.length} projects; use --project NAME or --all`, ); } } return [findProject(cfg, name)]; }