Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
3 changes: 2 additions & 1 deletion packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 33 additions & 1 deletion packages/gittensory-miner/lib/laptop-init.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,36 @@ export function verifyGithubToken(options?: {
timeoutMs?: number;
}): Promise<GithubTokenVerification>;

export function runInit(args?: string[], env?: Record<string, string | undefined>): Promise<number>;
/** 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<string, string> }
| { ok: false; error: string };

export function runInteractiveInitWizard(streams?: InteractiveInitStreams): Promise<InteractiveInitWizardResult>;

export function runInit(
args?: string[],
env?: Record<string, string | undefined>,
streams?: InteractiveInitStreams,
): Promise<number>;
230 changes: 226 additions & 4 deletions packages/gittensory-miner/lib/laptop-init.js
Original file line number Diff line number Diff line change
@@ -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://github.com/ghapi";
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"
Expand Down Expand Up @@ -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 ?? "" });
Expand All @@ -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,
),
Expand All @@ -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;
}
Loading