diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 8e61dc4841..48a7d64ef5 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 setup without hand-reading this README: `gittensory-miner init --interactive` prompts for a masked `GITHUB_TOKEN` and a coding-agent provider (`claude-cli` / `codex-cli` / `agent-sdk` / `noop`), plus that provider's optional model/timeout overrides, writes a starter `.env` to the state dir (not auto-loaded — source it into your shell, or point a service's env-file setting at it), and automatically reruns `doctor` so you immediately see whether the new config passes. It never prints the token back, makes no network calls beyond what `doctor` already makes (none), and plain `init` with no flag is 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..6c3f2de1fb 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -58,7 +58,15 @@ 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") { - process.exit(await runInit(cliArgs.slice(1))); + const initArgs = cliArgs.slice(1); + const initExitCode = await runInit(initArgs); + // #5176: init --interactive mutates process.env in place with the just-collected values (see runInit), so a + // doctor rerun right here sees the fresh config -- giving the operator the same "does this pass" readout doctor + // always provides, without duplicating its check list or output formatting here. + if (initExitCode === 0 && initArgs.includes("--interactive")) { + process.exit(runDoctor(initArgs.filter((flag) => flag !== "--interactive"))); + } + process.exit(initExitCode); } if (cliArgs[0] === "status") { diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index c1722c6a5b..a9a6df0727 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -16,7 +16,8 @@ export function printHelp(input) { " gittensory-miner --version", " gittensory-miner help", " gittensory-miner version", - " gittensory-miner init [--json] [--verify-token] Bootstrap laptop-mode local SQLite state", + " gittensory-miner init [--json] [--verify-token] [--interactive] Bootstrap laptop-mode local SQLite state", + " --interactive prompts for GITHUB_TOKEN + provider, writes a starter .env, then reruns 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/laptop-init.d.ts b/packages/gittensory-miner/lib/laptop-init.d.ts index 8cf942b591..d18f4de234 100644 --- a/packages/gittensory-miner/lib/laptop-init.d.ts +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -50,4 +50,36 @@ export function verifyGithubToken(options?: { timeoutMs?: number; }): Promise; -export function runInit(args?: string[], env?: Record): Promise; +/** The minimal input-stream surface `init --interactive`'s prompts actually use — deliberately narrower than + * `NodeJS.ReadableStream` so an injected test double doesn't have to implement the full stream contract + * (`pipe`/`read`/`unpipe`/etc.) it never calls. `setEncoding`/`setRawMode` are optional: real TTY stdin has + * both, a piped/injected stream may have neither. */ +export type InteractiveInitInputStream = { + on: (event: "data", listener: (chunk: string) => void) => unknown; + removeListener: (event: "data", listener: (chunk: string) => void) => unknown; + resume: () => unknown; + pause: () => unknown; + setEncoding?: (encoding: string) => unknown; + setRawMode?: (mode: boolean) => unknown; +}; + +export type InteractiveInitOutputStream = { + write: (chunk: string) => unknown; +}; + +export type InteractiveInitStreams = { + input?: InteractiveInitInputStream; + output?: InteractiveInitOutputStream; +}; + +export type InteractiveInitWizardResult = + | { ok: true; values: Record } + | { ok: false; error: string }; + +export function runInteractiveInitWizard(streams?: InteractiveInitStreams): Promise; + +export function runInit( + args?: string[], + env?: Record, + streams?: InteractiveInitStreams, +): Promise; diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index 6db4509225..36d7d1e06b 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -1,15 +1,21 @@ -import { accessSync, chmodSync, constants, existsSync, mkdirSync } from "node:fs"; +import { accessSync, chmodSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { applySchemaMigrations } from "./schema-version.js"; -import { reportCliFailure } from "./cli-error.js"; +import { describeCliError, reportCliFailure } from "./cli-error.js"; const githubApiBaseUrl = "https://api.github.com"; const githubApiVersion = "2022-11-28"; const classicRepoScopes = new Set(["repo", "public_repo"]); const defaultDbFileName = "laptop-state.sqlite3"; +/** Menu order for `init --interactive`'s provider prompt (#5176). Kept as a local literal — mirrors + * `CODING_AGENT_DRIVER_NAMES` in packages/gittensory-engine/src/miner/driver-factory.ts — rather than an + * import, since this package never depends on gittensory-engine at runtime (see checkClaudeCliPresent / + * checkCodexCliPresent above, which hardcode "claude-cli" / "codex-cli" the same way). */ +const CODING_AGENT_PROVIDERS = Object.freeze(["claude-cli", "codex-cli", "agent-sdk", "noop"]); + /** Local state directory (mirrors `resolveMinerStateDir` in status.js — kept local to avoid import cycles). */ function resolveMinerStateDir(env = process.env) { const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string" @@ -301,9 +307,216 @@ export function checkCodexCliPresent(options = {}) { return { name: "codex-cli-present", ok: true, detail }; } -export async function runInit(args = [], env = process.env) { +/** + * Reads one line of input a byte/keystroke at a time, echoing either the real character (`mask: false`, e.g. the + * provider menu) or `*` (`mask: true`, GITHUB_TOKEN) to `output` -- never the raw character in the masked case + * (#5176). One shared reader for both prompt kinds, rather than layering a manual raw-mode reader for the masked + * prompt on top of `node:readline` for the unmasked ones: two independent input-consumption mechanisms on the + * same stream in one process is a real footgun (readline's own internal buffering vs. this file's), and a single + * mechanism is far simpler to reason about and to test against an injected stream. + * + * On a real TTY, raw mode is required to suppress the terminal's own cooked-mode echo of what's typed (and to + * receive Ctrl+C as data rather than a SIGINT); on an injected/piped stream (no `setRawMode`, e.g. in tests) that + * branch is simply skipped -- cooked-mode line editing already happened upstream in that case. + * + * `reader.leftover` carries any bytes consumed past the terminator from ONE prompt into the NEXT: a piped/non-TTY + * stdin can (and in practice does) deliver several answers -- e.g. a token line AND the next menu selection -- in + * a single "data" chunk, since pipes have no notion of "one keystroke per event" the way a real TTY in raw mode + * does. Discarding everything after the first newline in that chunk would silently drop the next prompt's answer + * and hang the wizard forever waiting for input that already arrived. `reader` is created once per wizard run + * (see runInteractiveInitWizard) and threaded through every prompt in that run so the carry-over is preserved + * across calls; the same input stream reused by a LATER, unrelated call gets a fresh reader with empty leftover. + * + * Resolves with the typed value (untrimmed; callers trim), or rejects on Ctrl+C. + */ +function promptRaw(io, question, mask) { + const { input, output, reader } = io; + output.write(question); + return new Promise((resolve, reject) => { + let value = ""; + // Raw mode is only needed to suppress the terminal's own cooked-mode echo, so only the masked (GITHUB_TOKEN) + // prompt engages it -- the unmasked provider/model/timeout prompts stay in cooked mode, where the OS's own + // line editing (and its own echo of `char`, mirrored by this file's write below) already does the right thing. + const canSetRawMode = mask && typeof input.setRawMode === "function"; + if (canSetRawMode) input.setRawMode(true); + if (typeof input.setEncoding === "function") input.setEncoding("utf8"); + input.resume(); + + const finish = (remainder) => { + input.removeListener("data", onData); + if (canSetRawMode) input.setRawMode(false); + input.pause(); + reader.leftover = remainder; + }; + + // Returns true once this prompt has resolved or rejected from `text` alone (a fully-answered chunk with + // input still pending after it) -- the caller must stop feeding this reader more text in that case. + const consume = (text) => { + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === "\r" || char === "\n") { + finish(text.slice(i + 1)); + output.write("\n"); + resolve(value); + return true; + } + if (char === "\u0003") { + finish(""); + reject(new Error("aborted by operator (Ctrl+C)")); + return true; + } + if (char === "\u007f" || char === "\b") { + if (value.length > 0) { + value = value.slice(0, -1); + if (mask) output.write("\b \b"); + } + continue; + } + value += char; + output.write(mask ? "*" : char); + } + return false; + }; + + const onData = (chunk) => { + consume(String(chunk)); + }; + + if (reader.leftover) { + const pending = reader.leftover; + reader.leftover = ""; + if (consume(pending)) return; + } + input.on("data", onData); + }); +} + +function promptLine(io, question) { + return promptRaw(io, question, false).then((value) => value.trim()); +} + +function promptMasked(io, question) { + return promptRaw(io, question, true); +} + +/** A blank answer means "skip this optional var" (#5176) -- returns `null` rather than an empty string so + * callers can `if (value)` without also excluding a deliberately-cleared-then-retyped value. */ +async function promptOptionalLine(io, question) { + const answer = await promptLine(io, question); + return answer.length > 0 ? answer : null; +} + +async function promptGithubToken(io) { + for (;;) { + const token = await promptMasked(io, "GitHub token (repo-scoped PAT, input hidden): "); + if (token.trim().length > 0) return token.trim(); + io.output.write("A non-empty GITHUB_TOKEN is required.\n"); + } +} + +async function promptProvider(io) { + io.output.write("\nSelect a coding-agent provider (\"noop\" configures none for now):\n"); + CODING_AGENT_PROVIDERS.forEach((name, index) => { + io.output.write(` ${index + 1}) ${name}\n`); + }); + for (;;) { + const answer = await promptLine(io, `Provider [1-${CODING_AGENT_PROVIDERS.length}]: `); + const index = Number.parseInt(answer, 10); + if (Number.isInteger(index) && index >= 1 && index <= CODING_AGENT_PROVIDERS.length) { + return CODING_AGENT_PROVIDERS[index - 1]; + } + io.output.write(`Please enter a number between 1 and ${CODING_AGENT_PROVIDERS.length}.\n`); + } +} + +/** Provider-specific companion prompts (#5176) -- mirrors CODING_AGENT_DRIVER_CONFIG_ENV in + * packages/gittensory-engine/src/miner/driver-factory.ts: `claude-cli` and `codex-cli` each take an optional + * model override plus the shared timeout var; `agent-sdk` and `noop` take neither. */ +async function promptProviderCompanions(io, provider) { + const values = {}; + if (provider !== "claude-cli" && provider !== "codex-cli") return values; + + const modelEnvVar = provider === "claude-cli" ? "MINER_CODING_AGENT_CLAUDE_MODEL" : "MINER_CODING_AGENT_CODEX_MODEL"; + const cliName = provider === "claude-cli" ? "claude" : "codex"; + const model = await promptOptionalLine(io, `Model override for ${cliName} (leave blank for its own default): `); + if (model) values[modelEnvVar] = model; + + const timeoutMs = await promptOptionalLine( + io, + "Attempt timeout in ms (leave blank for the driver default, 120000): ", + ); + if (timeoutMs) values.MINER_CODING_AGENT_TIMEOUT_MS = timeoutMs; + + return values; +} + +/** + * Interactive credential/provider wizard for `init --interactive` (#5176). Never makes a network call itself -- + * that stays scoped to the separate, explicitly opt-in `--verify-token` flag. Returns the collected values (never + * echoing GITHUB_TOKEN back, including in the printed summary) or `{ ok: false }` if the operator aborts. + */ +export async function runInteractiveInitWizard(streams = {}) { + const io = { + input: streams.input ?? process.stdin, + output: streams.output ?? process.stdout, + reader: { leftover: "" }, + }; + try { + io.output.write("gittensory-miner interactive setup\n"); + io.output.write("-----------------------------------\n"); + const githubToken = await promptGithubToken(io); + const provider = await promptProvider(io); + const companions = await promptProviderCompanions(io, provider); + const values = { GITHUB_TOKEN: githubToken, MINER_CODING_AGENT_PROVIDER: provider, ...companions }; + + io.output.write("\nCollected configuration:\n"); + io.output.write(" GITHUB_TOKEN: (provided, hidden)\n"); + for (const [key, value] of Object.entries(companions)) io.output.write(` ${key}: ${value}\n`); + io.output.write(` MINER_CODING_AGENT_PROVIDER: ${provider}\n`); + + return { ok: true, values }; + } catch (error) { + return { ok: false, error: describeCliError(error) }; + } +} + +/** Writes the values collected by {@link runInteractiveInitWizard} to a starter `.env` file in the state dir + * (#5176). Not auto-loaded by this CLI (mirrors the existing `.gittensory-miner.env.example` / systemd + * `EnvironmentFile=` convention, README.md's "Bare-host (systemd, no Docker)" section) — an operator sources it + * into their shell or points a service's env-file setting at it. */ +function writeStarterEnvFile(env, values) { + const stateDir = resolveMinerStateDir(env); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const path = join(stateDir, ".env"); + const lines = [ + `# gittensory-miner starter env, written by \`gittensory-miner init --interactive\` on ${new Date().toISOString()}.`, + "# Not auto-loaded by this CLI -- source it into your shell, or point a service's env-file setting at it.", + "# Keep this file out of version control and treat it like a secret.", + ...Object.entries(values).map(([key, value]) => `${key}=${value}`), + ]; + writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); + return { path }; +} + +export async function runInit(args = [], env = process.env, streams = {}) { const verifyToken = args.includes("--verify-token"); const jsonOutput = args.includes("--json"); + const interactive = args.includes("--interactive"); + + let wizard = null; + if (interactive) { + wizard = await runInteractiveInitWizard(streams); + if (!wizard.ok) { + return reportCliFailure(jsonOutput, wizard.error, 1); + } + // Mutates the caller's env in place (defaults to process.env) so the just-collected values are visible to + // the rest of THIS invocation -- the --verify-token/initLaptopState calls right below, and (for the real + // CLI entry point) the doctor rerun that follows init --interactive, without threading a merged-env object + // through every layer for a one-shot interactive command. + Object.assign(env, wizard.values); + } + let verification = null; if (verifyToken) { verification = await verifyGithubToken({ githubToken: env.GITHUB_TOKEN ?? "" }); @@ -313,10 +526,16 @@ export async function runInit(args = [], env = process.env) { } const result = initLaptopState(env); + const envFile = interactive ? writeStarterEnvFile(env, wizard.values) : null; + if (jsonOutput) { console.log( JSON.stringify( - verification ? { ...result, tokenVerification: verification } : result, + { + ...result, + ...(verification ? { tokenVerification: verification } : {}), + ...(envFile ? { envFile: envFile.path } : {}), + }, null, 2, ), @@ -327,6 +546,9 @@ export async function runInit(args = [], env = process.env) { if (verification) { console.log(`token: ${verification.detail}`); } + if (envFile) { + console.log(`env file: ${envFile.path}`); + } } return 0; } diff --git a/test/unit/miner-init-interactive.test.ts b/test/unit/miner-init-interactive.test.ts new file mode 100644 index 0000000000..aa10ade21c --- /dev/null +++ b/test/unit/miner-init-interactive.test.ts @@ -0,0 +1,410 @@ +import { EventEmitter } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runInit, runInteractiveInitWizard } from "../../packages/gittensory-miner/lib/laptop-init.js"; + +const roots: string[] = []; + +function tempRoot() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-init-interactive-")); + roots.push(root); + return root; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +/** + * A fake stdin: each queued "line" is the entire raw byte sequence a prompt should receive (may embed + * backspace/Ctrl+C control characters to exercise those paths). Overriding `on` to auto-emit the next queued + * line, on a fresh microtask, the instant a "data" listener is (re-)registered makes this robust against the + * wizard's own await-driven prompt sequencing -- no fixed sleeps or manual tick-flushing needed, since a new + * listener is only ever registered exactly when the wizard is ready for the next answer. + */ +class ScriptedInput extends EventEmitter { + queue: string[]; + rawModeCalls: boolean[] = []; + + constructor( + answers: string[], + { hasSetRawMode = false, hasSetEncoding = true }: { hasSetRawMode?: boolean; hasSetEncoding?: boolean } = {}, + ) { + super(); + this.queue = [...answers]; + if (hasSetRawMode) { + (this as unknown as { setRawMode: (mode: boolean) => void }).setRawMode = (mode: boolean) => { + this.rawModeCalls.push(mode); + }; + } + if (hasSetEncoding) { + (this as unknown as { setEncoding: () => void }).setEncoding = () => {}; + } + } + + override on(event: string, listener: (chunk: string) => void) { + super.on(event, listener); + if (event === "data") { + const next = this.queue.shift(); + if (next !== undefined) queueMicrotask(() => this.emit("data", next)); + } + return this; + } + + resume() { + return this; + } + + pause() { + return this; + } +} + +function fakeOutput() { + const chunks: string[] = []; + return { chunks, write: (chunk: string) => (chunks.push(chunk), true) }; +} + +describe("gittensory-miner init --interactive wizard (#5176)", () => { + it("collects GITHUB_TOKEN + provider and skips companion prompts for noop", async () => { + const input = new ScriptedInput(["ghp_mytoken\n", "4\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result).toEqual({ ok: true, values: { GITHUB_TOKEN: "ghp_mytoken", MINER_CODING_AGENT_PROVIDER: "noop" } }); + expect(output.chunks.join("")).not.toContain("Model override"); + }); + + it("re-prompts on an empty GITHUB_TOKEN before accepting a non-empty one", async () => { + const input = new ScriptedInput(["\n", " \n", "ghp_real\n", "4\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.values.GITHUB_TOKEN).toBe("ghp_real"); + expect(output.chunks.join("")).toContain("A non-empty GITHUB_TOKEN is required."); + }); + + it("re-prompts on an invalid provider selection before accepting a valid one", async () => { + // Final answer "4" (noop) deliberately avoids claude-cli/codex-cli so this test stays scoped to menu + // validation alone -- picking either of those would pull in two more (companion-var) prompts this test + // doesn't script answers for. + const input = new ScriptedInput(["ghp_x\n", "0\n", "9\n", "not-a-number\n", "4\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.values.MINER_CODING_AGENT_PROVIDER).toBe("noop"); + const text = output.chunks.join(""); + expect(text.match(/Please enter a number between 1 and 4\./g)?.length).toBe(3); + }); + + it("lists providers in claude-cli/codex-cli/agent-sdk/noop order", async () => { + const input = new ScriptedInput(["ghp_x\n", "4\n"]); + const output = fakeOutput(); + await runInteractiveInitWizard({ input, output }); + const text = output.chunks.join(""); + expect(text).toContain("1) claude-cli"); + expect(text).toContain("2) codex-cli"); + expect(text).toContain("3) agent-sdk"); + expect(text).toContain("4) noop"); + }); + + it("collects claude-cli's model + timeout companions when provided", async () => { + const input = new ScriptedInput(["ghp_x\n", "1\n", "claude-opus\n", "600000\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.values.MINER_CODING_AGENT_CLAUDE_MODEL).toBe("claude-opus"); + expect(result.values.MINER_CODING_AGENT_TIMEOUT_MS).toBe("600000"); + } + }); + + it("codex-cli prompts for its own model env var, not claude's", async () => { + const input = new ScriptedInput(["ghp_x\n", "2\n", "codex-mini\n", "\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.values.MINER_CODING_AGENT_CODEX_MODEL).toBe("codex-mini"); + expect(result.values).not.toHaveProperty("MINER_CODING_AGENT_CLAUDE_MODEL"); + expect(result.values).not.toHaveProperty("MINER_CODING_AGENT_TIMEOUT_MS"); + } + }); + + it("companion vars are skippable: a blank line leaves them unset rather than empty-stringed", async () => { + const input = new ScriptedInput(["ghp_x\n", "1\n", "\n", "\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.values).not.toHaveProperty("MINER_CODING_AGENT_CLAUDE_MODEL"); + expect(result.values).not.toHaveProperty("MINER_CODING_AGENT_TIMEOUT_MS"); + expect(Object.keys(result.values).sort()).toEqual(["GITHUB_TOKEN", "MINER_CODING_AGENT_PROVIDER"]); + } + }); + + it("agent-sdk skips companion prompts entirely, same as noop", async () => { + const input = new ScriptedInput(["ghp_x\n", "3\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) expect(Object.keys(result.values).sort()).toEqual(["GITHUB_TOKEN", "MINER_CODING_AGENT_PROVIDER"]); + expect(output.chunks.join("")).not.toContain("Model override"); + }); + + it("masked input honors backspace edits mid-entry", async () => { + const input = new ScriptedInput(["ab\u007fc\n", "4\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.values.GITHUB_TOKEN).toBe("ac"); + }); + + it("toggles raw mode on then off around the masked token prompt when the stream supports it", async () => { + const input = new ScriptedInput(["ghp_x\n", "4\n"], { hasSetRawMode: true }); + const output = fakeOutput(); + await runInteractiveInitWizard({ input, output }); + expect(input.rawModeCalls).toEqual([true, false]); + }); + + it("never touches setRawMode when the stream doesn't expose it (piped/non-TTY input)", async () => { + const input = new ScriptedInput(["ghp_x\n", "4\n"]); + expect((input as unknown as { setRawMode?: unknown }).setRawMode).toBeUndefined(); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + }); + + it("tolerates an input stream with no setEncoding method", async () => { + const input = new ScriptedInput(["ghp_x\n", "4\n"], { hasSetEncoding: false }); + expect((input as unknown as { setEncoding?: unknown }).setEncoding).toBeUndefined(); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + }); + + it("a leading backspace on an empty value is a harmless no-op", async () => { + const input = new ScriptedInput(["\u007fabc\n", "4\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.values.GITHUB_TOKEN).toBe("abc"); + }); + + it("backspace during an unmasked prompt edits the value without emitting the masked erase sequence", async () => { + const input = new ScriptedInput(["ghp_x\n", "3\u007f4\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.values.MINER_CODING_AGENT_PROVIDER).toBe("noop"); + expect(output.chunks.join("")).not.toContain("\b \b"); + }); + + it("falls back to process.stdin/process.stdout when no streams are injected", async () => { + const fakeStdin = new ScriptedInput(["ghp_default\n", "4\n"]); + const fakeStdoutChunks: string[] = []; + const fakeStdout = { write: (chunk: string) => (fakeStdoutChunks.push(chunk), true) }; + const originalStdin = Object.getOwnPropertyDescriptor(process, "stdin"); + const originalStdout = Object.getOwnPropertyDescriptor(process, "stdout"); + Object.defineProperty(process, "stdin", { value: fakeStdin, configurable: true }); + Object.defineProperty(process, "stdout", { value: fakeStdout, configurable: true }); + try { + const result = await runInteractiveInitWizard(); + expect(result.ok).toBe(true); + if (result.ok) expect(result.values.GITHUB_TOKEN).toBe("ghp_default"); + expect(fakeStdoutChunks.join("")).toContain("gittensory-miner interactive setup"); + } finally { + if (originalStdin) Object.defineProperty(process, "stdin", originalStdin); + if (originalStdout) Object.defineProperty(process, "stdout", originalStdout); + } + }); + + it("REGRESSION: multiple answers delivered in a single data chunk (piped/non-TTY stdin) are not dropped", async () => { + // A real piped stdin (e.g. `printf 'token\\n4\\n' | gittensory-miner init --interactive`) has no notion of + // "one keystroke per event" -- everything already written to the pipe can arrive as ONE "data" chunk. Discarding + // everything after the first prompt's terminator (as an earlier version of this wizard did) drops the next + // prompt's answer and hangs forever waiting for input that already arrived. + const input = new EventEmitter() as EventEmitter & { resume: () => void; pause: () => void }; + input.resume = () => {}; + input.pause = () => {}; + const output = fakeOutput(); + + const wizardPromise = runInteractiveInitWizard({ input, output }); + input.emit("data", "ghp_onechunk\n4\n"); + const result = await wizardPromise; + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.values.GITHUB_TOKEN).toBe("ghp_onechunk"); + expect(result.values.MINER_CODING_AGENT_PROVIDER).toBe("noop"); + } + }); + + it("accumulates a value across several separate data events (real TTY raw-mode keystroke delivery)", async () => { + const input = new EventEmitter() as EventEmitter & { resume: () => void; pause: () => void }; + input.resume = () => {}; + input.pause = () => {}; + const output = fakeOutput(); + + const wizardPromise = runInteractiveInitWizard({ input, output }); + for (const char of "ghp_keystroke") input.emit("data", char); + input.emit("data", "\n"); + // Let every pending microtask (the token prompt resolving, its await chain unwinding up through + // promptGithubToken/runInteractiveInitWizard, and promptProvider registering its own "data" listener) settle + // before sending the next answer -- a real TTY delivers keystrokes on separate ticks too, never faster than + // the process can react. + await new Promise((resolve) => setImmediate(resolve)); + input.emit("data", "4\n"); + const result = await wizardPromise; + + expect(result.ok).toBe(true); + if (result.ok) expect(result.values.GITHUB_TOKEN).toBe("ghp_keystroke"); + }); + + it("carries a PARTIAL leftover (no terminator yet) into a fresh data listener rather than resolving early", async () => { + // The leftover from the token prompt ("4", no trailing newline) is a real but incomplete answer to the next + // prompt -- it must be combined with whatever arrives in a LATER, separate chunk, not treated as already-done. + const input = new EventEmitter() as EventEmitter & { resume: () => void; pause: () => void }; + input.resume = () => {}; + input.pause = () => {}; + const output = fakeOutput(); + + const wizardPromise = runInteractiveInitWizard({ input, output }); + input.emit("data", "ghp_partial\n4"); + await new Promise((resolve) => setImmediate(resolve)); + input.emit("data", "\n"); + const result = await wizardPromise; + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.values.GITHUB_TOKEN).toBe("ghp_partial"); + expect(result.values.MINER_CODING_AGENT_PROVIDER).toBe("noop"); + } + }); + + it("REGRESSION/invariant: the raw GITHUB_TOKEN value never appears in any output write, including the summary", async () => { + const secret = "ghp_super_secret_do_not_leak_1234567890"; + const input = new ScriptedInput([`${secret}\n`, "1\n", "\n", "\n"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result.ok).toBe(true); + for (const chunk of output.chunks) expect(chunk).not.toContain(secret); + const text = output.chunks.join(""); + expect(text).toContain("*".repeat(secret.length)); + expect(text).toContain("GITHUB_TOKEN: (provided, hidden)"); + }); + + it("aborts cleanly (ok: false) on Ctrl+C during the token prompt, with no values leaked", async () => { + const input = new ScriptedInput(["abc\u0003"]); + const output = fakeOutput(); + const result = await runInteractiveInitWizard({ input, output }); + expect(result).toEqual({ ok: false, error: expect.stringContaining("Ctrl+C") }); + }); +}); + +describe("gittensory-miner runInit --interactive (#5176)", () => { + it("writes a starter .env (mode 0600) under the state dir and mutates env in place", async () => { + const root = tempRoot(); + const env: Record = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const input = new ScriptedInput(["ghp_written\n", "1\n", "opus\n", "\n"]); + const output = fakeOutput(); + vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runInit(["--interactive"], env, { input, output }); + + expect(exitCode).toBe(0); + expect(env.GITHUB_TOKEN).toBe("ghp_written"); + expect(env.MINER_CODING_AGENT_PROVIDER).toBe("claude-cli"); + expect(env.MINER_CODING_AGENT_CLAUDE_MODEL).toBe("opus"); + + const envPath = join(root, "state", ".env"); + expect(existsSync(envPath)).toBe(true); + expect(statSync(envPath).mode & 0o777).toBe(0o600); + const contents = readFileSync(envPath, "utf8"); + expect(contents).toContain("GITHUB_TOKEN=ghp_written"); + expect(contents).toContain("MINER_CODING_AGENT_PROVIDER=claude-cli"); + expect(contents).toContain("MINER_CODING_AGENT_CLAUDE_MODEL=opus"); + expect(contents).not.toContain("MINER_CODING_AGENT_TIMEOUT_MS"); + }); + + it("prints the env file path in plain-text output", async () => { + const root = tempRoot(); + const env: Record = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const input = new ScriptedInput(["ghp_x\n", "4\n"]); + const output = fakeOutput(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runInit(["--interactive"], env, { input, output }); + + const lines = log.mock.calls.map((call) => String(call[0])); + expect(lines.some((line) => line.startsWith("env file: ") && line.includes(join(root, "state", ".env")))).toBe(true); + }); + + it("--json output includes the envFile path alongside the standard init payload", async () => { + const root = tempRoot(); + const env: Record = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const input = new ScriptedInput(["ghp_x\n", "4\n"]); + const output = fakeOutput(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runInit(["--interactive", "--json"], env, { input, output }); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.envFile).toBe(join(root, "state", ".env")); + expect(payload.created).toBe(true); + }); + + it("never prints the raw GITHUB_TOKEN to console.log, only to the .env file", async () => { + const root = tempRoot(); + const secret = "ghp_console_leak_check_abcdef"; + const env: Record = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const input = new ScriptedInput([`${secret}\n`, "4\n"]); + const output = fakeOutput(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runInit(["--interactive"], env, { input, output }); + + for (const call of log.mock.calls) expect(String(call[0])).not.toContain(secret); + const contents = readFileSync(join(root, "state", ".env"), "utf8"); + expect(contents).toContain(secret); + }); + + it("aborts without creating the state dir or the .env file when the wizard is aborted", async () => { + const root = tempRoot(); + const stateDir = join(root, "state"); + const env: Record = { GITTENSORY_MINER_CONFIG_DIR: stateDir }; + const input = new ScriptedInput(["abc\u0003"]); + const output = fakeOutput(); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runInit(["--interactive"], env, { input, output }); + + expect(exitCode).toBe(1); + expect(existsSync(stateDir)).toBe(false); + }); + + it("REGRESSION: non-interactive init is byte-for-byte unchanged and never reads the injected streams", async () => { + const root = tempRoot(); + const env: Record = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const input = new ScriptedInput([]); + const untouchedInput = { + on: () => { + throw new Error("non-interactive init must never read from input"); + }, + }; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + const exitCode = await runInit([], env, { input: untouchedInput as never, output: fakeOutput() }); + + expect(exitCode).toBe(0); + expect(log.mock.calls).toHaveLength(2); + expect(String(log.mock.calls[0]?.[0])).toBe(`initialized ${join(root, "state")}`); + expect(String(log.mock.calls[1]?.[0])).toContain("sqlite: "); + expect(String(log.mock.calls[1]?.[0])).not.toContain("already existed"); + expect(existsSync(join(root, "state", ".env"))).toBe(false); + expect(input.queue).toHaveLength(0); + }); +});