Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/gittensory-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
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 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
Expand Down
9 changes: 9 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)));
}

Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions packages/gittensory-miner/lib/init-wizard.d.ts
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 };
149 changes: 149 additions & 0 deletions packages/gittensory-miner/lib/init-wizard.js
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);

Copy link
Copy Markdown
Contributor

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
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="packages/gittensory-miner/lib/init-wizard.js">
<violation number="1" location="packages/gittensory-miner/lib/init-wizard.js:95">
<priority>P2</priority>
<title>.env file written with default permissions before chmodSync restricts access</title>
<evidence>writeFileSync(envFilePath, renderWizardEnvFile(entries)); followed by chmodSync(envFilePath, 0o600); creates the credential file with the process&apos;s default umask permissions, leaving a small but exploitable window where other users or processes could read the freshly written GITHUB_TOKEN.</evidence>
<recommendation>Pass { mode: 0o600 } as the third argument to writeFileSync so the file is created atomically with restrictive permissions, eliminating the TOCTOU race condition.</recommendation>
</violation>
</file>

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();
},
};
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
Loading