diff --git a/packages/gittensory-miner/DEPLOYMENT.md b/packages/gittensory-miner/DEPLOYMENT.md index 19a9393875..e591c8e838 100644 --- a/packages/gittensory-miner/DEPLOYMENT.md +++ b/packages/gittensory-miner/DEPLOYMENT.md @@ -32,6 +32,7 @@ For provider selection and the CLI-specific model/timeout overrides, see gittensory-miner status gittensory-miner doctor gittensory-miner init --verify-token # optional: validate GITHUB_TOKEN once before attempts + gittensory-miner init --interactive # optional: guided prompt for GITHUB_TOKEN + provider, writes a starter .env, then reruns doctor ``` 3. Expected layout after first use (default paths): diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 8e61dc4841..c1565f0a56 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -174,6 +174,8 @@ gittensory-miner status `init` creates `~/.config/gittensory-miner/` (or `GITTENSORY_MINER_CONFIG_DIR` / `XDG_CONFIG_HOME` overrides) and a local `laptop-state.sqlite3` bootstrap file. Re-running `init` is idempotent. Pass `--verify-token` to make one authenticated GitHub API call up front and fail fast if `GITHUB_TOKEN` is invalid or missing repository access scopes. `doctor` reports Node, the state directory, SQLite readiness, and whether Docker is installed (informational only). Every local store already applies its own pending schema migrations automatically the moment some other command first opens it, but `migrate` lets an operator proactively bring every EXISTING store file up to date in one pass (e.g. right after upgrading) instead of relying on whichever command happens to touch a given store first; a store file that hasn't been created yet is reported as skipped, not created. +First-time operators can instead run `gittensory-miner init --interactive` (#5176): a guided prompt for `GITHUB_TOKEN` (input hidden, never echoed or written to any log) and an optional coding-agent provider — plus that provider's model/timeout companion vars, each individually skippable with Enter — writes a starter `.env` to the state dir, then automatically reruns `doctor` against the collected values so setup problems surface immediately. `--interactive` makes no network calls of its own beyond what `doctor` already makes (none); non-interactive `init` invocations are unaffected. + From a local checkout: ```sh diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index d2507921a6..9cbc8dad31 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -20,6 +20,7 @@ import { runOrbExportCli } from "../lib/orb-export.js"; import { installCliSignalHandlers } from "../lib/process-lifecycle.js"; import { runStateCli } from "../lib/run-state-cli.js"; import { runInit } from "../lib/laptop-init.js"; +import { createWizardIo, runInteractiveInit } from "../lib/init-wizard.js"; import { loadMinerFileSecrets } from "../lib/env-file-indirection.js"; import { runMigrate } from "../lib/migrate-cli.js"; import { runDoctor, runStatus } from "../lib/status.js"; @@ -58,6 +59,14 @@ configureLogger({ ...logOptions, env: process.env }); // Dispatch the local commands BEFORE the opportunistic npm-registry update check is even started, so they can // never reach that network path (the update check runs for the remaining commands below). if (cliArgs[0] === "init") { + if (cliArgs.includes("--interactive")) { + const wizardIo = createWizardIo(); + try { + process.exit(await runInteractiveInit(process.env, process.cwd(), wizardIo)); + } finally { + wizardIo.close(); + } + } process.exit(await runInit(cliArgs.slice(1))); } diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index c1722c6a5b..2d528b36c3 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -17,6 +17,7 @@ export function printHelp(input) { " gittensory-miner help", " gittensory-miner version", " gittensory-miner init [--json] [--verify-token] Bootstrap laptop-mode local SQLite state", + " gittensory-miner init --interactive Guided first-run wizard: prompts for GITHUB_TOKEN + provider, writes a starter .env, then runs doctor", " gittensory-miner status [--json] Show installed versions + local state paths", " gittensory-miner doctor [--json] Check this laptop is set up correctly", " gittensory-miner migrate [--json] Apply pending schema migrations to existing local stores", diff --git a/packages/gittensory-miner/lib/init-wizard.d.ts b/packages/gittensory-miner/lib/init-wizard.d.ts new file mode 100644 index 0000000000..47b0b7019e --- /dev/null +++ b/packages/gittensory-miner/lib/init-wizard.d.ts @@ -0,0 +1,25 @@ +export type WizardIo = { + promptText(question: string): Promise; + promptMasked(question: string): Promise; + writeLine(text: string): void; + close?: () => void; +}; + +export function resolveWizardEnvFilePath(env?: Record): string; + +export function renderWizardEnvFile(entries: ReadonlyArray): string; + +export function promptProviderSelection(io: WizardIo): Promise; + +export function promptCompanionVars(io: WizardIo, provider: string): Promise>; + +export function runInteractiveInit( + env: Record, + cwd: string, + io: WizardIo, +): Promise; + +export function createWizardIo( + input?: NodeJS.ReadableStream, + output?: NodeJS.WritableStream, +): WizardIo & { close: () => void }; diff --git a/packages/gittensory-miner/lib/init-wizard.js b/packages/gittensory-miner/lib/init-wizard.js new file mode 100644 index 0000000000..cc2f6b1241 --- /dev/null +++ b/packages/gittensory-miner/lib/init-wizard.js @@ -0,0 +1,149 @@ +import { createInterface } from "node:readline"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { CODING_AGENT_DRIVER_CONFIG_ENV, CODING_AGENT_DRIVER_NAMES } from "@loopover/engine"; +import { initLaptopState } from "./laptop-init.js"; +import { resolveMinerStateDir, runDoctor } from "./status.js"; + +// First-run onboarding wizard for `gittensory-miner init --interactive` (#5176): prompts for a GITHUB_TOKEN +// (masked, never echoed to stdout/logs) and an optional coding-agent provider + its companion vars, writes them +// to a starter .env in the state dir, then reruns the existing offline `doctor` checks against the collected +// values so the operator sees pass/fail immediately. Makes no network calls of its own -- `doctor` is offline +// by contract (status.js), and this module never calls verifyGithubToken (that stays behind the separate, +// explicitly opt-in `init --verify-token` flag). + +const COMPANION_VAR_LABELS = { model: "model override", timeoutMs: "timeout in milliseconds" }; + +/** Where the wizard writes its starter .env file: the miner state dir, the same directory `init` already uses + * for laptop-state.sqlite3. */ +export function resolveWizardEnvFilePath(env = process.env) { + return join(resolveMinerStateDir(env), ".env"); +} + +/** Render collected `[KEY, value]` pairs as sourceable `KEY=value` lines, one per entry, insertion order. Pure + * and filesystem-free so it is directly testable. */ +export function renderWizardEnvFile(entries) { + if (entries.length === 0) return ""; + return `${entries.map(([key, value]) => `${key}=${value}`).join("\n")}\n`; +} + +async function promptRequiredMasked(io, question) { + for (;;) { + const answer = (await io.promptMasked(question)).trim(); + if (answer) return answer; + io.writeLine("A value is required -- please try again."); + } +} + +/** + * Menu selection sourced from the engine's own `CODING_AGENT_DRIVER_NAMES`, so the choices can never drift from + * what the driver factory actually resolves. Empty input SKIPS provider selection entirely (leaves + * MINER_CODING_AGENT_PROVIDER unwritten, deferring to whatever default the CLI already resolves) -- distinct + * from explicitly choosing the `noop` entry. + */ +export async function promptProviderSelection(io) { + io.writeLine("Select a coding-agent provider (press Enter to skip and use the default):"); + CODING_AGENT_DRIVER_NAMES.forEach((name, index) => { + io.writeLine(` ${index + 1}) ${name}`); + }); + for (;;) { + const answer = (await io.promptText(`Provider [1-${CODING_AGENT_DRIVER_NAMES.length}, or Enter to skip]: `)).trim(); + if (!answer) return null; + const index = Number(answer) - 1; + if (Number.isInteger(index) && index >= 0 && index < CODING_AGENT_DRIVER_NAMES.length) { + return CODING_AGENT_DRIVER_NAMES[index]; + } + io.writeLine(`Enter a number from 1 to ${CODING_AGENT_DRIVER_NAMES.length}, or press Enter to skip.`); + } +} + +/** + * Optional, skippable per-provider companion vars (model override / timeout), sourced from the same + * `CODING_AGENT_DRIVER_CONFIG_ENV` map the real driver factory reads -- never a hand-duplicated var-name list + * that could drift. Empty input skips that one var; its built-in default (if any) applies at run time as usual. + */ +export async function promptCompanionVars(io, provider) { + const varsForProvider = CODING_AGENT_DRIVER_CONFIG_ENV[provider] ?? {}; + const collected = []; + for (const [kind, envVarName] of Object.entries(varsForProvider)) { + const label = COMPANION_VAR_LABELS[kind]; + const answer = (await io.promptText(`Optional ${label} for ${provider} (env ${envVarName}) [Enter to skip]: `)).trim(); + if (answer) collected.push([envVarName, answer]); + } + return collected; +} + +/** + * Run the interactive onboarding wizard end to end: collect GITHUB_TOKEN + optional provider config, write the + * starter .env, initialize laptop state, then rerun the existing offline doctor checks against the collected + * values. Returns doctor's exit code. `io` is injected so tests never touch a real terminal. + */ +export async function runInteractiveInit(env, cwd, io) { + const githubToken = await promptRequiredMasked(io, "GitHub token (input hidden): "); + const provider = await promptProviderSelection(io); + + const entries = [["GITHUB_TOKEN", githubToken]]; + if (provider) { + entries.push(["MINER_CODING_AGENT_PROVIDER", provider]); + entries.push(...(await promptCompanionVars(io, provider))); + } + + const stateDir = resolveMinerStateDir(env); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const envFilePath = resolveWizardEnvFilePath(env); + // { mode: 0o600 } on writeFileSync applies only when the file is newly created -- an existing file (e.g. from + // a prior wizard run, or hand-created by the operator with looser permissions) keeps its current mode across + // a write. The chmodSync below still runs unconditionally so the end state is always 0600 either way; the + // writeFileSync mode option exists so a BRAND NEW file is never briefly readable at the default umask + // permissions between being created and being locked down. + writeFileSync(envFilePath, renderWizardEnvFile(entries), { mode: 0o600 }); + chmodSync(envFilePath, 0o600); + io.writeLine(`wrote ${envFilePath}`); + + const initResult = initLaptopState(env); + io.writeLine(`initialized ${initResult.stateDir}`); + io.writeLine(`sqlite: ${initResult.dbPath}${initResult.created ? "" : " (already existed)"}`); + + const mergedEnv = { ...env }; + for (const [key, value] of entries) mergedEnv[key] = value; + + io.writeLine(""); + io.writeLine("Running doctor against the new configuration:"); + return runDoctor([], mergedEnv, cwd); +} + +/** + * Real terminal I/O for the wizard. Masked input is implemented by overriding readline's own output-write hook + * to render `*` instead of the typed prompt's characters while the interface is still doing its normal + * cooked-mode line editing (Enter/Backspace all still work exactly as with a plain prompt) -- no raw-mode byte + * handling and no extra dependency. `input`/`output` are parameters (defaulting to the real stdio) purely so + * tests can drive the exact same code path with fake streams instead of a real terminal. + */ +export function createWizardIo(input = process.stdin, output = process.stdout) { + const rl = createInterface({ input, output, terminal: true }); + const originalWriteToOutput = rl._writeToOutput.bind(rl); + let masking = false; + rl._writeToOutput = (stringToWrite) => { + originalWriteToOutput(masking ? "*" : stringToWrite); + }; + return { + promptText(question) { + return new Promise((resolve) => rl.question(question, resolve)); + }, + promptMasked(question) { + return new Promise((resolve) => { + rl.question(question, (answer) => { + masking = false; + resolve(answer); + }); + masking = true; + }); + }, + writeLine(text) { + output.write(`${text}\n`); + }, + close() { + rl.close(); + }, + }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 723b5bce11..d52294aab5 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -38,7 +38,7 @@ ], "scripts": { "benchmark": "node scripts/benchmark.mjs", - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@loopover/engine": "*", diff --git a/test/unit/miner-init-wizard.test.ts b/test/unit/miner-init-wizard.test.ts new file mode 100644 index 0000000000..01b4a9061d --- /dev/null +++ b/test/unit/miner-init-wizard.test.ts @@ -0,0 +1,254 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; +import { CODING_AGENT_DRIVER_NAMES } from "@loopover/engine"; +import { + createWizardIo, + promptCompanionVars, + promptProviderSelection, + renderWizardEnvFile, + resolveWizardEnvFilePath, + runInteractiveInit, +} from "../../packages/gittensory-miner/lib/init-wizard.js"; +import { runCliResult } from "./support/miner-cli-harness"; + +const roots: string[] = []; + +function tempRoot() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-init-wizard-")); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function createFakeIo(options: { maskedAnswers?: string[]; textAnswers?: string[] } = {}) { + const lines: string[] = []; + const maskedQueue = [...(options.maskedAnswers ?? [])]; + const textQueue = [...(options.textAnswers ?? [])]; + return { + lines, + async promptMasked(question: string) { + lines.push(`MASKED?${question}`); + return maskedQueue.shift() ?? ""; + }, + async promptText(question: string) { + lines.push(`TEXT?${question}`); + return textQueue.shift() ?? ""; + }, + writeLine(text: string) { + lines.push(text); + }, + }; +} + +function createFakeTty() { + let output = ""; + const outStream = new Writable({ + write(chunk, _enc, cb) { + output += chunk.toString(); + cb(); + }, + }); + Object.assign(outStream, { isTTY: true, columns: 80 }); + const inStream = new Readable({ read() {} }); + Object.assign(inStream, { isTTY: true }); + return { inStream, outStream, getOutput: () => output }; +} + +async function typeLine(inStream: Readable, text: string) { + for (const ch of text) inStream.emit("data", Buffer.from(ch)); + inStream.emit("data", Buffer.from("\n")); +} + +describe("gittensory-miner init --interactive wizard (#5176)", () => { + it("renderWizardEnvFile renders sourceable KEY=value lines in insertion order, or empty for no entries", () => { + expect(renderWizardEnvFile([])).toBe(""); + expect(renderWizardEnvFile([["GITHUB_TOKEN", "ghp_x"]])).toBe("GITHUB_TOKEN=ghp_x\n"); + expect( + renderWizardEnvFile([ + ["GITHUB_TOKEN", "ghp_x"], + ["MINER_CODING_AGENT_PROVIDER", "claude-cli"], + ]), + ).toBe("GITHUB_TOKEN=ghp_x\nMINER_CODING_AGENT_PROVIDER=claude-cli\n"); + }); + + it("resolveWizardEnvFilePath writes to the same state dir init already uses", () => { + expect(resolveWizardEnvFilePath({ GITTENSORY_MINER_CONFIG_DIR: "/custom/state" })).toBe( + "/custom/state/.env", + ); + }); + + describe("promptProviderSelection", () => { + it("returns null (skip) on empty input, without validation errors", async () => { + const io = createFakeIo({ textAnswers: [""] }); + expect(await promptProviderSelection(io)).toBeNull(); + expect(io.lines.some((line) => line.includes("Enter a number"))).toBe(false); + }); + + it("re-prompts on an invalid/out-of-range answer, then accepts a valid one", async () => { + const io = createFakeIo({ textAnswers: ["abc", "99", "2"] }); + expect(await promptProviderSelection(io)).toBe(CODING_AGENT_DRIVER_NAMES[1]); + const reprompts = io.lines.filter((line) => line.includes("Enter a number from 1")); + expect(reprompts).toHaveLength(2); + }); + + it("menu choices are sourced directly from CODING_AGENT_DRIVER_NAMES, never a hand-duplicated list", async () => { + const io = createFakeIo({ textAnswers: [String(CODING_AGENT_DRIVER_NAMES.length)] }); + expect(await promptProviderSelection(io)).toBe( + CODING_AGENT_DRIVER_NAMES[CODING_AGENT_DRIVER_NAMES.length - 1], + ); + for (const name of CODING_AGENT_DRIVER_NAMES) { + expect(io.lines.some((line) => line.includes(name))).toBe(true); + } + }); + }); + + describe("promptCompanionVars", () => { + it("prompts for claude-cli's model + timeout, skipping any left empty", async () => { + const io = createFakeIo({ textAnswers: ["claude-model-x", ""] }); + const result = await promptCompanionVars(io, "claude-cli"); + expect(result).toEqual([["MINER_CODING_AGENT_CLAUDE_MODEL", "claude-model-x"]]); + }); + + it("prompts for nothing for a provider with no companion vars (noop)", async () => { + const io = createFakeIo(); + expect(await promptCompanionVars(io, "noop")).toEqual([]); + expect(io.lines).toHaveLength(0); + }); + + it("prompts for nothing for a provider name not in the config-env map (defensive default)", async () => { + const io = createFakeIo(); + expect(await promptCompanionVars(io, "not-a-real-provider")).toEqual([]); + expect(io.lines).toHaveLength(0); + }); + }); + + describe("runInteractiveInit", () => { + it("writes a starter .env (mode 0600), initializes laptop state, and passes a clean doctor run when the provider is skipped", async () => { + const stateDir = join(tempRoot(), "state"); + const cwd = tempRoot(); // no .gittensory-miner.yml here => config-content check passes + const env = { GITTENSORY_MINER_CONFIG_DIR: stateDir }; + const io = createFakeIo({ maskedAnswers: ["ghp_test_token_123"], textAnswers: [""] }); + + const exitCode = await runInteractiveInit(env, cwd, io); + + const envFilePath = join(stateDir, ".env"); + expect(existsSync(envFilePath)).toBe(true); + expect(readFileSync(envFilePath, "utf8")).toBe("GITHUB_TOKEN=ghp_test_token_123\n"); + expect(statSync(envFilePath).mode & 0o777).toBe(0o600); + expect(existsSync(join(stateDir, "laptop-state.sqlite3"))).toBe(true); + + // REGRESSION (#5176): the raw token must never appear in anything written to the terminal, including + // the final doctor summary this function prints. + expect(io.lines.some((line) => line.includes("ghp_test_token_123"))).toBe(false); + + // No provider was configured, so doctor's coding-agent-credential check is a clean skip and every other + // check passes deterministically in this environment (same healthy-setup shape as miner-status.test.ts). + expect(exitCode).toBe(0); + }); + + it("re-prompts when the token is left empty before accepting a valid one", async () => { + const stateDir = join(tempRoot(), "state"); + const cwd = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: stateDir }; + const io = createFakeIo({ maskedAnswers: ["", "ghp_after_retry"], textAnswers: [""] }); + + await runInteractiveInit(env, cwd, io); + + expect(readFileSync(join(stateDir, ".env"), "utf8")).toBe("GITHUB_TOKEN=ghp_after_retry\n"); + expect(io.lines.some((line) => line.includes("A value is required"))).toBe(true); + }); + + it("writes the selected provider and its filled-in companion var, skipping the one left empty", async () => { + const stateDir = join(tempRoot(), "state"); + const cwd = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: stateDir }; + const claudeIndex = CODING_AGENT_DRIVER_NAMES.indexOf("claude-cli"); + const io = createFakeIo({ + maskedAnswers: ["ghp_provider_case"], + textAnswers: [String(claudeIndex + 1), "opus-x", ""], + }); + + await runInteractiveInit(env, cwd, io); + + expect(readFileSync(join(stateDir, ".env"), "utf8")).toBe( + "GITHUB_TOKEN=ghp_provider_case\nMINER_CODING_AGENT_PROVIDER=claude-cli\nMINER_CODING_AGENT_CLAUDE_MODEL=opus-x\n", + ); + }); + + it("reports the sqlite file as already existing on a second run against the same state dir", async () => { + const stateDir = join(tempRoot(), "state"); + const cwd = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: stateDir }; + + await runInteractiveInit(env, cwd, createFakeIo({ maskedAnswers: ["ghp_first"], textAnswers: [""] })); + const secondIo = createFakeIo({ maskedAnswers: ["ghp_second"], textAnswers: [""] }); + await runInteractiveInit(env, cwd, secondIo); + + expect(secondIo.lines.some((line) => line.includes("(already existed)"))).toBe(true); + }); + }); + + describe("createWizardIo (real terminal adapter, driven over fake streams)", () => { + it("promptText resolves the typed line", async () => { + const { inStream, outStream } = createFakeTty(); + const io = createWizardIo(inStream, outStream); + const answerPromise = io.promptText("Provider: "); + await typeLine(inStream, "claude-cli"); + expect(await answerPromise).toBe("claude-cli"); + io.close(); + }); + + it("REGRESSION: promptMasked never writes the raw secret to the output stream", async () => { + const { inStream, outStream, getOutput } = createFakeTty(); + const io = createWizardIo(inStream, outStream); + const answerPromise = io.promptMasked("GitHub token (input hidden): "); + await typeLine(inStream, "ghp_supersecret123"); + const answer = await answerPromise; + io.close(); + + expect(answer).toBe("ghp_supersecret123"); + expect(getOutput()).not.toContain("ghp_supersecret123"); + expect(getOutput()).toContain("*"); + }); + + it("stops masking once the masked prompt resolves, so a later plain prompt echoes normally", async () => { + const { inStream, outStream, getOutput } = createFakeTty(); + const io = createWizardIo(inStream, outStream); + + const maskedPromise = io.promptMasked("Secret: "); + await typeLine(inStream, "hunter2"); + await maskedPromise; + + const textPromise = io.promptText("Provider: "); + await typeLine(inStream, "noop"); + expect(await textPromise).toBe("noop"); + expect(getOutput()).toContain("noop"); + io.close(); + }); + + it("writeLine writes the text followed by a newline", () => { + const { inStream, outStream, getOutput } = createFakeTty(); + const io = createWizardIo(inStream, outStream); + io.writeLine("hello"); + expect(getOutput()).toBe("hello\n"); + io.close(); + }); + }); + + it("e2e: `gittensory-miner init --interactive` dispatches to the wizard, not the non-interactive path", () => { + // No stdin input is piped, so the wizard blocks on its first prompt and the process is torn down once + // Node detects the unsettled top-level await -- this only asserts the CLI routes `--interactive` to the + // wizard (distinct prompt text, distinct code path) without hanging; the full multi-turn prompt flow is + // exercised precisely and deterministically by the direct runInteractiveInit tests above. + const stateDir = tempRoot(); + const result = runCliResult(["init", "--interactive"], { GITTENSORY_MINER_CONFIG_DIR: stateDir }); + expect(result.output).toContain("GitHub token (input hidden)"); + expect(result.output).not.toContain("initialized " + stateDir); + }); +});