-
-
Notifications
You must be signed in to change notification settings - Fork 86
feat(miner): add a gittensory-miner init --interactive first-run onboarding wizard #5621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| export type WizardIo = { | ||
| promptText(question: string): Promise<string>; | ||
| promptMasked(question: string): Promise<string>; | ||
| writeLine(text: string): void; | ||
| close?: () => void; | ||
| }; | ||
|
|
||
| export function resolveWizardEnvFilePath(env?: Record<string, string | undefined>): string; | ||
|
|
||
| export function renderWizardEnvFile(entries: ReadonlyArray<readonly [string, string]>): string; | ||
|
|
||
| export function promptProviderSelection(io: WizardIo): Promise<string | null>; | ||
|
|
||
| export function promptCompanionVars(io: WizardIo, provider: string): Promise<Array<[string, string]>>; | ||
|
|
||
| export function runInteractiveInit( | ||
| env: Record<string, string | undefined>, | ||
| cwd: string, | ||
| io: WizardIo, | ||
| ): Promise<number>; | ||
|
|
||
| export function createWizardIo( | ||
| input?: NodeJS.ReadableStream, | ||
| output?: NodeJS.WritableStream, | ||
| ): WizardIo & { close: () => void }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: .env file written with default permissions before chmodSync restricts access
.env created with default umask permissions before chmodSync locks it down.
Pass { mode: 0o600 } to writeFileSync to create the file with secure permissions atomically.
AI prompt