/** * `setup` — interview, then write config.json, the labels, the briefs and the * staged host files behind one confirm. * * Moved out of cli.ts's switch by the per-verb module split (#462); * only the case wrapper, the injected `ctx` lookups and the imports * changed from the original bodies. */ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import type { CommandContext } from "./context.ts"; import type { CompletionShell } from "./complete.ts"; import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "../config.ts"; import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts"; import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts"; import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts"; import { telegramStateDir } from "../fleet.ts"; import { answersUi, guardNonInteractiveUi, loadAnswersFile, readSetupResume, recordingAnswersUi, saveAnswersFile, saveSetupResume, setupConfigHash, type RecordedAnswersUi, } from "../setup-answers.ts"; import { terminalUi, type WizardUi } from "../wizard-ui.ts"; import { interactiveUi } from "../ui/progress.ts"; import type { ConductorConfig, ProjectConfig } from "../types.ts"; /** * The setup verb's own usage, printed for `setup --help` / `setup -h` before * any UI, config read, probe or area parsing. Areas come from `AMEND_AREA_IDS` * rather than a second copy, so an added amend area appears both here and in * the shared `USAGE` with no third spelling to keep in sync. */ const SETUP_USAGE = `omp-conductor setup — interview, then write config.json, the labels, the briefs and the staged host files behind one confirm. usage: omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--resume FILE] [--project NAME] omp-conductor setup host [NAME] [--project NAME] omp-conductor setup graph [--no-seed] [--print] [--project NAME] Bare setup runs the full interview — or, when the project already exists, asks which area to amend. Naming an area positionally skips that chooser and amends only that area. \`setup host\` and \`setup graph\` are install subcommands, not areas: the NAME (or --project NAME) says which project to install for. amend areas: ${AMEND_AREA_IDS.join(", ")} flags: --project NAME the project to configure or amend (or NAME positionally for \`setup host\`) --no-ai ask every question, propose nothing (no AI repo reads) --answers FILE answer every prompt from JSON; never opens a prompt --save-answers FILE save successful prompt answers as replayable JSON --resume FILE replay the answers a failed apply kept, bound to the config they were given against; refuses a changed config`; /** * Resolve the project an install subcommand (`setup host`, `setup graph`) * installs for — the flag, or the positional form an operator naturally * types on a multi-project host (`setup host conductor`). The pair may not * disagree, and an unknown name fails loudly as "Unknown project …" rather * than being silently ignored (#514). */ export function setupInstallProject( cfg: ConductorConfig, projectFlag: string | undefined, positionalName: string | undefined, ): ProjectConfig { if (projectFlag !== undefined && positionalName !== undefined && projectFlag !== positionalName) { throw new Error( `setup names two different projects (--project ${projectFlag} and ${positionalName}) — pass one`, ); } return findProject(cfg, projectFlag ?? positionalName); } export async function installShellCompletions( shell: Extract, home: string = homedir(), ): Promise<{ scriptPath: string; rcPath: string }> { const completionDir = join(home, ".omp", "conductor"); mkdirSync(completionDir, { recursive: true }); const scriptPath = join(completionDir, `completions.${shell}`); writeFileSync( scriptPath, (await import("./complete.ts")).renderCompletionScript(shell), "utf8", ); const rcPath = join(home, shell === "zsh" ? ".zshrc" : ".bashrc"); const sourceLine = `[ -f ~/.omp/conductor/completions.${shell} ] && ` + `source ~/.omp/conductor/completions.${shell}`; const current = existsSync(rcPath) ? readFileSync(rcPath, "utf8") : ""; if (!current.split(/\r?\n/).includes(sourceLine)) { const separator = current.length > 0 && !current.endsWith("\n") ? "\n" : ""; appendFileSync(rcPath, `${separator}${sourceLine}\n`, "utf8"); } return { scriptPath, rcPath }; } async function offerCompletionInstall(ui: WizardUi): Promise { const install = await ui.confirm( "Install shell completions?", "Writes a generated completion script and one idempotent source line to your shell rc file.", { key: "install-shell-completions" }, ); if (install !== true) return; const detected = basename(process.env.SHELL ?? ""); const selected = await ui.select( "Shell", [ { label: "zsh", description: "Install through ~/.zshrc" }, { label: "bash", description: "Install through ~/.bashrc" }, ], { key: "completion-shell", initialIndex: detected === "bash" ? 1 : 0 }, ); if (selected !== "zsh" && selected !== "bash") return; const installed = await installShellCompletions(selected); ui.notify(`Installed completions at ${installed.scriptPath}; sourced from ${installed.rcPath}.`); } /** This build's version — the schema fence a resume file is checked against. */ const PACKAGE_VERSION: string = ((): string => { try { const raw: unknown = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8")); const version = raw !== null && typeof raw === "object" ? Reflect.get(raw, "version") : undefined; return typeof version === "string" && version.length > 0 ? version : "unknown"; } catch { return "unknown"; } })(); /** The live config generation a resume file must match, or `absent`. */ function liveSetupConfigHash(): string { return setupConfigHash(() => { try { return readFileSync(configPath(), "utf8"); } catch { return undefined; } }); } /** * Where a failed post-interview apply leaves its answers (#864), per project so * two fleets on one host cannot resume each other's interview. */ export function setupResumePath(project: string | undefined): string { return join(stateDir(), `setup-resume-${project ?? "host"}.json`); } function setupValueFlag(argv: readonly string[], name: "--answers" | "--save-answers" | "--resume"): string | undefined { const inline = argv.find((arg) => arg.startsWith(`${name}=`)); const at = argv.indexOf(name); if (inline !== undefined && at !== -1) throw new Error(`${name} may be passed only once`); const value = inline?.slice(name.length + 1) ?? (at === -1 ? undefined : argv[at + 1]); if (value === undefined) { if (at !== -1) throw new Error(`${name} requires a file path`); return undefined; } if (value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a file path`); return value; } export async function setupCommand(ctx: CommandContext): Promise { // Help first, and only in the first trailing position: a help request // must never open a UI, read config, probe GitHub or pause dispatch. // `setup bogus --help` still rejects `bogus` as an unknown area, and // `setup caps --help` still names `caps` as the governing argument. if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") { process.stdout.write(`${SETUP_USAGE}\n`); return; } const sub = ctx.argv[1]; const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined; // Clack owns an interactive terminal. Pipes retain the byte-pinned readline // protocol, while --answers installs a prompt-free driver. const answersPath = setupValueFlag(ctx.argv, "--answers"); const saveAnswersPath = setupValueFlag(ctx.argv, "--save-answers"); // `--resume` is `--answers` bound to the config it was answered against (#864): // the apply phase failed, the interview did not, and re-typing it is the cost // this flag removes. The binding is checked before a single prompt is skipped, // because replaying answers over a config that changed since is how an old plan // silently overwrites newer policy. const resumePath = setupValueFlag(ctx.argv, "--resume"); const resumed = resumePath === undefined ? undefined : readSetupResume(resumePath, liveSetupConfigHash(), PACKAGE_VERSION); if (resumed !== undefined && !resumed.ok) { process.stderr.write(`omp-conductor: setup: ${resumed.problem}\n`); process.exitCode = 2; return; } const scriptedAnswers = resumed?.ok === true ? resumed.file.answers : undefined; const useClack = answersPath === undefined && scriptedAnswers === undefined && interactiveUi(); const baseUi = scriptedAnswers ? answersUi(scriptedAnswers) : answersPath ? answersUi(loadAnswersFile(answersPath)) : useClack ? (await import("../clack-ui.ts")).clackUi() : terminalUi(); const guardedUi = answersPath === undefined && scriptedAnswers === undefined && !process.stdin.isTTY ? guardNonInteractiveUi(baseUi) : baseUi; // Always recording now, not only under `--save-answers`: the answers of a failed // apply are exactly what the retry needs, and a flag nobody passed in advance // cannot help the failure that has already happened. const recording: RecordedAnswersUi = recordingAnswersUi(guardedUi); const ui = recording.ui; let saveCompletedAnswers = false; let closeMessage = "Setup failed."; let applyFailed = false; try { // `host` and `graph` are install subcommands, checked BEFORE the amend // areas. They are not areas — routing them through the area validation // is how `setup host` came to exit 2 as an "unknown setup area". if (positional === "host" || positional === "graph") { const cfg = loadConfig(); // `setup host `: the project an operator would naturally type. // The positional only ever names a project; anything else that does not // look like a flag is refused by findProject below rather than being // quietly ignored. const positionalName = positional === "host" && ctx.argv[2] !== undefined && !ctx.argv[2].startsWith("--") ? ctx.argv[2] : undefined; let outcome: InstallOutcome; if (positional === "host") { // No `--project` and no positional → a host-global install (#530): the // units are host-global, so a multi-project host installs them with no // name and skips the per-project tail (tick config, brief link) with a // named note; a name resolves and wires the tail. const project = ctx.projectFlag === undefined && positionalName === undefined ? undefined : setupInstallProject(cfg, ctx.projectFlag, positionalName); outcome = await runHostInstall( project, project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults), telegramStateDir(), ui, ); } else { // `setup graph` is inherently per-project repositories; there is no // host-global graph to install, so a project must resolve. outcome = await runGraphInstall(findProject(cfg, ctx.projectFlag), ui, { noSeed: ctx.argv.includes("--no-seed"), print: ctx.argv.includes("--print"), }); } // `staged` is a success on a host that has no systemd: the files are // real, only the enable step is impossible. if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1); saveCompletedAnswers = outcome.kind !== "declined"; closeMessage = outcome.kind === "declined" ? "Setup incomplete." : "Setup complete."; return; } let area: AmendAreaId | undefined; if (positional !== undefined) { if (!(AMEND_AREA_IDS as readonly string[]).includes(positional)) { process.stderr.write( `omp-conductor: unknown setup argument "${positional}" — ` + `install subcommands are host, graph; amend areas are ${AMEND_AREA_IDS.join(", ")}\n`, ); process.exit(2); } area = positional as AmendAreaId; } // `--no-ai` is the interview with its reading half removed: every question // is still asked, nothing is proposed. For a host with no omp peer, a // private repo no probe can clone, or an operator who would rather type the // gates than review a model's reading of their CI. const completed = await setup( ui, ctx.projectFlag, area, ctx.argv.includes("--no-ai") ? NO_PROBES : DEFAULT_PROBES, ); if (completed && process.stdin.isTTY && process.stdout.isTTY) { try { await offerCompletionInstall(ui); } catch (err) { ui.notify( `Skipped completion install: ${err instanceof Error ? err.message : String(err)}`, "warning", ); } } saveCompletedAnswers = completed; closeMessage = completed ? "Setup complete." : "Setup incomplete."; } catch (err) { // The interview succeeded and the apply phase did not — the shape of the // 2026-08-21 incident, where five retries re-asked every question while the // real fault was a runtime gate. Keep the answers, bound to the config they // were given against, and name the exact command that replays them. applyFailed = true; throw err; } finally { try { if (applyFailed && Object.keys(recording.answers).length > 0) { const path = setupResumePath(ctx.projectFlag); saveSetupResume(path, { configHash: liveSetupConfigHash(), version: PACKAGE_VERSION, ...(positional === undefined ? {} : { area: positional }), ...(ctx.projectFlag === undefined ? {} : { project: ctx.projectFlag }), answers: recording.answers, }); ui.notify( `Answers kept — retry without re-answering:\n omp-conductor setup${ positional === undefined ? "" : ` ${positional}` }${ctx.projectFlag === undefined ? "" : ` --project ${ctx.projectFlag}`} --resume ${path}`, "warning", ); } if (saveCompletedAnswers && saveAnswersPath !== undefined) { saveAnswersFile(saveAnswersPath, recording.answers); ui.notify(`Saved setup answers to ${saveAnswersPath}.`); } // A completed setup retires its own resume file: a stale one invites a // replay of answers that have already been applied. if (saveCompletedAnswers) rmSync(setupResumePath(ctx.projectFlag), { force: true }); } catch (err) { closeMessage = "Setup failed."; throw err; } finally { ui.close(closeMessage); } } }