diff --git a/packages/gittensory-miner/lib/attempt-cli.js b/packages/gittensory-miner/lib/attempt-cli.js index 986705074e..f93879dc81 100644 --- a/packages/gittensory-miner/lib/attempt-cli.js +++ b/packages/gittensory-miner/lib/attempt-cli.js @@ -13,6 +13,7 @@ // query (attempt-log.js's schema has no repo+issue index, and reenqueue counts aren't tracked anywhere yet). import { resolveCodingAgentModeFromConfig } from "@jsonbored/gittensory-engine"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; import { runSlopAssessment } from "./slop-assessment.js"; import { fetchLiveIssueSnapshot } from "./live-issue-snapshot.js"; @@ -139,8 +140,7 @@ export function buildAttemptDeps(env, ledgers) { export async function runAttempt(args, options = {}) { const parsed = parseAttemptArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } const env = options.env ?? process.env; @@ -149,10 +149,11 @@ export async function runAttempt(args, options = {}) { const mode = resolveMode({ env, agentDryRun: !parsed.live }); if (mode === "paused") { - console.error( + return reportCliFailure( + parsed.json, `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, + 3, ); - return 3; } const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; @@ -222,9 +223,12 @@ export async function runAttempt(args, options = {}) { const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`); - return 3; + const reason = describeCliError(error); + return reportCliFailure( + parsed.json, + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`, + 3, + ); } // Real worktree preparation (repo-clone.js + attempt-worktree.js, #5237): the allocator above only @@ -461,8 +465,7 @@ export async function runAttempt(args, options = {}) { return 2; } } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } finally { // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call // happens; every earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since diff --git a/packages/gittensory-miner/lib/calibration-cli.js b/packages/gittensory-miner/lib/calibration-cli.js index b9ea26971e..abc8567610 100644 --- a/packages/gittensory-miner/lib/calibration-cli.js +++ b/packages/gittensory-miner/lib/calibration-cli.js @@ -6,6 +6,7 @@ import { buildCalibrationReport } from "./calibration.js"; import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js"; import { MINER_PR_OUTCOME_EVENT } from "./pr-outcome.js"; import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; +import { reportCliFailure, describeCliError } from "./cli-error.js"; const CALIBRATION_USAGE = "Usage: gittensory-miner calibration [--json]"; @@ -67,13 +68,14 @@ export function runCalibrationCli(args = [], env = process.env) { const json = args.includes("--json"); const unknown = args.find((token) => token.startsWith("-") && token !== "--json"); if (unknown) { - console.error(`Unknown option: ${unknown}. ${CALIBRATION_USAGE}`); - return 1; + return reportCliFailure(json, `Unknown option: ${unknown}. ${CALIBRATION_USAGE}`, 1); } - const predictionStore = initPredictionLedger(resolvePredictionLedgerDbPath(env)); - const eventLedger = initEventLedger(resolveEventLedgerDbPath(env)); + let predictionStore; + let eventLedger; try { + predictionStore = initPredictionLedger(resolvePredictionLedgerDbPath(env)); + eventLedger = initEventLedger(resolveEventLedgerDbPath(env)); const report = buildCalibrationReport( toPredictionRecords(predictionStore.readPredictions()), toOutcomeRecords(eventLedger.readEvents()), @@ -81,8 +83,10 @@ export function runCalibrationCli(args = [], env = process.env) { if (json) console.log(JSON.stringify(report, null, 2)); else renderReportText(report); return 0; + } catch (error) { + return reportCliFailure(json, describeCliError(error)); } finally { - predictionStore.close(); - eventLedger.close(); + predictionStore?.close(); + eventLedger?.close(); } } diff --git a/packages/gittensory-miner/lib/claim-ledger-cli.js b/packages/gittensory-miner/lib/claim-ledger-cli.js index 724d76d19a..1dab93db74 100644 --- a/packages/gittensory-miner/lib/claim-ledger-cli.js +++ b/packages/gittensory-miner/lib/claim-ledger-cli.js @@ -1,4 +1,5 @@ import { CLAIM_STATUSES, openClaimLedger } from "./claim-ledger.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const CLAIM_CLAIM_USAGE = "Usage: gittensory-miner claim claim [--note ] [--json]"; @@ -183,8 +184,7 @@ function withClaimLedger(options, run) { export function runClaimClaim(args, options = {}) { const parsed = parseClaimClaimArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -202,24 +202,21 @@ export function runClaimClaim(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runClaimRelease(args, options = {}) { const parsed = parseClaimReleaseArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { return withClaimLedger(options, (claimLedger) => { const claim = claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); if (!claim) { - console.error("claim_not_found"); - return 2; + return reportCliFailure(parsed.json, "claim_not_found"); } if (parsed.json) { console.log(JSON.stringify({ claim }, null, 2)); @@ -229,16 +226,14 @@ export function runClaimRelease(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runClaimList(args, options = {}) { const parsed = parseClaimListArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -255,8 +250,7 @@ export function runClaimList(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } @@ -264,6 +258,5 @@ export function runClaimCli(subcommand, args, options = {}) { if (subcommand === "claim") return runClaimClaim(args, options); if (subcommand === "release") return runClaimRelease(args, options); if (subcommand === "list") return runClaimList(args, options); - console.error(`Unknown claim subcommand: ${subcommand ?? ""}. ${CLAIM_LIST_USAGE}`); - return 2; + return reportCliFailure(argsWantJson(args), `Unknown claim subcommand: ${subcommand ?? ""}. ${CLAIM_LIST_USAGE}`); } diff --git a/packages/gittensory-miner/lib/cli-error.d.ts b/packages/gittensory-miner/lib/cli-error.d.ts new file mode 100644 index 0000000000..ba262d6435 --- /dev/null +++ b/packages/gittensory-miner/lib/cli-error.d.ts @@ -0,0 +1,3 @@ +export function reportCliFailure(wantsJson: boolean, message: string, exitCode?: number): number; +export function argsWantJson(args: readonly string[]): boolean; +export function describeCliError(error: unknown): string; diff --git a/packages/gittensory-miner/lib/cli-error.js b/packages/gittensory-miner/lib/cli-error.js new file mode 100644 index 0000000000..c20a132723 --- /dev/null +++ b/packages/gittensory-miner/lib/cli-error.js @@ -0,0 +1,27 @@ +/** Shared CLI failure output (#4836): when `--json` is set, emit a parseable `{ ok: false, error }` object on + * stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */ + +/** + * @param {boolean} wantsJson + * @param {string} message + * @param {number} [exitCode] + * @returns {number} + */ +export function reportCliFailure(wantsJson, message, exitCode = 2) { + if (wantsJson) { + console.log(JSON.stringify({ ok: false, error: message }, null, 2)); + } else { + console.error(message); + } + return exitCode; +} + +/** True when argv includes `--json` (used on parse-error paths before a full parse result exists). */ +export function argsWantJson(args) { + return args.includes("--json"); +} + +/** Normalize a thrown value to a safe error string for CLI output. */ +export function describeCliError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 8fea7553a9..e79dd45fb3 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -1,3 +1,5 @@ +import { argsWantJson, reportCliFailure } from "./cli-error.js"; + export function printVersion(input) { console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`); } @@ -56,6 +58,6 @@ export function printHelp(input) { export function runCli(cliArgs, input) { const command = cliArgs[0] ?? ""; - console.error(`Unknown command: ${command}. Run ${input.packageName} --help.`); - return 1; + const message = `Unknown command: ${command}. Run ${input.packageName} --help.`; + return reportCliFailure(argsWantJson(cliArgs), message, 1); } diff --git a/packages/gittensory-miner/lib/deny-check.js b/packages/gittensory-miner/lib/deny-check.js index 0d117f251d..dc1855c0fa 100644 --- a/packages/gittensory-miner/lib/deny-check.js +++ b/packages/gittensory-miner/lib/deny-check.js @@ -1,4 +1,5 @@ import { evaluateDenyHooks } from "./deny-hooks.js"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; const DENY_CHECK_USAGE = "Usage: gittensory-miner hooks check --tool --input [--json]"; @@ -59,8 +60,7 @@ export function parseDenyCheckArgs(args) { export function runDenyCheck(args) { const parsed = parseDenyCheckArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } const verdict = evaluateDenyHooks({ name: parsed.tool, input: parsed.input }); diff --git a/packages/gittensory-miner/lib/discover-cli.js b/packages/gittensory-miner/lib/discover-cli.js index 94444f9824..da090c80df 100644 --- a/packages/gittensory-miner/lib/discover-cli.js +++ b/packages/gittensory-miner/lib/discover-cli.js @@ -10,6 +10,7 @@ import { initPolicyDocCacheStore } from "./policy-doc-cache.js"; import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; import { enqueueRankedDiscovery } from "./portfolio-discovery.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const DISCOVER_USAGE = "Usage: gittensory-miner discover [...] | --search [--json] [--api-base-url ] [--token-env ]"; @@ -138,8 +139,7 @@ export function renderDiscoverSummary(result) { export async function runDiscover(args, options = {}) { const parsed = parseDiscoverArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a @@ -222,8 +222,7 @@ export async function runDiscover(args, options = {}) { } return 0; } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } finally { if (ownsPortfolioQueue) portfolioQueue.close(); if (ownsPolicyDocCache && policyDocCache) policyDocCache.close(); diff --git a/packages/gittensory-miner/lib/event-ledger-cli.js b/packages/gittensory-miner/lib/event-ledger-cli.js index bb83e62730..493d68375c 100644 --- a/packages/gittensory-miner/lib/event-ledger-cli.js +++ b/packages/gittensory-miner/lib/event-ledger-cli.js @@ -1,4 +1,5 @@ import { initEventLedger } from "./event-ledger.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const LEDGER_LIST_USAGE = "Usage: gittensory-miner ledger list [--repo ] [--since ] [--type ] [--json]"; @@ -217,8 +218,7 @@ function withEventLedger(options, run) { export function runLedgerList(args, options = {}) { const parsed = parseLedgerListArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -238,15 +238,13 @@ export function runLedgerList(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runLedgerMetrics(args, options = {}) { if (args.length > 0) { - console.error(EVENT_LEDGER_METRICS_USAGE); - return 2; + return reportCliFailure(argsWantJson(args), EVENT_LEDGER_METRICS_USAGE); } try { @@ -257,14 +255,12 @@ export function runLedgerMetrics(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(argsWantJson(args), describeCliError(error)); } } export function runLedgerCli(subcommand, args, options = {}) { if (subcommand === "list") return runLedgerList(args, options); if (subcommand === "metrics") return runLedgerMetrics(args, options); - console.error(`Unknown ledger subcommand: ${subcommand ?? ""}. ${LEDGER_LIST_USAGE}`); - return 2; + return reportCliFailure(argsWantJson(args), `Unknown ledger subcommand: ${subcommand ?? ""}. ${LEDGER_LIST_USAGE}`); } diff --git a/packages/gittensory-miner/lib/feasibility-cli.js b/packages/gittensory-miner/lib/feasibility-cli.js index da17d32c9f..a97b9227b4 100644 --- a/packages/gittensory-miner/lib/feasibility-cli.js +++ b/packages/gittensory-miner/lib/feasibility-cli.js @@ -2,6 +2,7 @@ * `buildFeasibilityVerdict` composer. Purely local — no network, no filesystem — so it never needs the * npm-registry update check other subcommands opt into. */ import { buildFeasibilityVerdict } from "@jsonbored/gittensory-engine"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; const CLAIM_STATUSES = ["unclaimed", "claimed", "solved", "unknown"]; const DUPLICATE_CLUSTER_RISKS = ["none", "low", "medium", "high"]; @@ -59,8 +60,7 @@ export function parseFeasibilityArgs(args) { export function runFeasibilityCli(args, options = {}) { const parsed = parseFeasibilityArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } const buildVerdict = options.buildFeasibilityVerdict ?? buildFeasibilityVerdict; diff --git a/packages/gittensory-miner/lib/governor-ledger-cli.js b/packages/gittensory-miner/lib/governor-ledger-cli.js index 0365c9445e..f5257e9c0f 100644 --- a/packages/gittensory-miner/lib/governor-ledger-cli.js +++ b/packages/gittensory-miner/lib/governor-ledger-cli.js @@ -1,4 +1,6 @@ /** Must match `GOVERNOR_LEDGER_EVENT_TYPES` in `@jsonbored/gittensory-engine`. */ +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; + const GOVERNOR_LEDGER_EVENT_TYPES = Object.freeze([ "allowed", "denied", @@ -109,8 +111,7 @@ async function withGovernorLedger(options, run) { export async function runGovernorList(args, options = {}) { const parsed = parseGovernorListArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -129,13 +130,11 @@ export async function runGovernorList(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export async function runGovernorCli(subcommand, args, options = {}) { if (subcommand === "list") return runGovernorList(args, options); - console.error(`Unknown governor subcommand: ${subcommand ?? ""}. ${GOVERNOR_LIST_USAGE}`); - return 2; + return reportCliFailure(argsWantJson(args), `Unknown governor subcommand: ${subcommand ?? ""}. ${GOVERNOR_LIST_USAGE}`); } diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index 57f8cded6d..6db4509225 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -3,6 +3,7 @@ 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"; const githubApiBaseUrl = "https://api.github.com"; const githubApiVersion = "2022-11-28"; @@ -307,8 +308,7 @@ export async function runInit(args = [], env = process.env) { if (verifyToken) { verification = await verifyGithubToken({ githubToken: env.GITHUB_TOKEN ?? "" }); if (!verification.ok) { - console.error(verification.detail); - return 1; + return reportCliFailure(jsonOutput, verification.detail, 1); } } diff --git a/packages/gittensory-miner/lib/loop-cli.js b/packages/gittensory-miner/lib/loop-cli.js index fc446c20c0..946c75cd5c 100644 --- a/packages/gittensory-miner/lib/loop-cli.js +++ b/packages/gittensory-miner/lib/loop-cli.js @@ -26,6 +26,7 @@ // attempt-input-builder.js's header already flags as out of scope here). import { checkMinerKillSwitch } from "./governor-kill-switch.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js"; import { openGovernorState } from "./governor-state.js"; import { initGovernorLedger } from "./governor-ledger.js"; @@ -195,8 +196,7 @@ function zeroConvergence() { export async function runLoop(args, options = {}) { const parsed = parseLoopArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } const env = options.env ?? process.env; @@ -208,10 +208,11 @@ export async function runLoop(args, options = {}) { try { governorState = (options.openGovernorState ?? openGovernorState)(); } catch (error) { - console.error( - `Loop refuses to start: governor state cannot be loaded: ${error instanceof Error ? error.message : String(error)}`, + return reportCliFailure( + parsed.json, + `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`, + 3, ); - return 3; } const eventLedger = (options.initEventLedger ?? initEventLedger)(); @@ -469,8 +470,7 @@ export async function runLoop(args, options = {}) { } return 0; } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } finally { governorState.close(); eventLedger.close(); diff --git a/packages/gittensory-miner/lib/manage-poll.js b/packages/gittensory-miner/lib/manage-poll.js index d44c086c8a..6cff3d4a78 100644 --- a/packages/gittensory-miner/lib/manage-poll.js +++ b/packages/gittensory-miner/lib/manage-poll.js @@ -5,6 +5,7 @@ import { formatManagedPrIdentifier, } from "./manage-status.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const MANAGE_POLL_USAGE = "Usage: gittensory-miner manage poll [--branch ] [--json]"; @@ -162,8 +163,7 @@ export async function recordManagePollSnapshot(input, options = {}) { export async function runManagePoll(args = [], options = {}) { const parsed = parseManagePollArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } const ownsEventLedger = options.initEventLedger === undefined; @@ -201,8 +201,7 @@ export async function runManagePoll(args = [], options = {}) { } return 0; } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } finally { if (ownsEventLedger) eventLedger.close(); if (ownsPortfolioQueue) portfolioQueue.close(); diff --git a/packages/gittensory-miner/lib/manage-status.js b/packages/gittensory-miner/lib/manage-status.js index d7be3fc0c8..1565fae6f8 100644 --- a/packages/gittensory-miner/lib/manage-status.js +++ b/packages/gittensory-miner/lib/manage-status.js @@ -1,6 +1,7 @@ import { initEventLedger } from "./event-ledger.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { initRunStateStore } from "./run-state.js"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; /** Event vocabulary for manage-phase PR snapshots written by manage poll. (#2325) */ export const MANAGE_PR_UPDATE_EVENT = "manage_pr_update"; @@ -207,8 +208,7 @@ export function parseManageStatusArgs(args = []) { export function runManageStatus(args = [], options = {}) { const parsed = parseManageStatusArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } const ownsPortfolioQueue = options.initPortfolioQueue === undefined; diff --git a/packages/gittensory-miner/lib/metrics-cli.js b/packages/gittensory-miner/lib/metrics-cli.js index d9cc32366e..30da05754a 100644 --- a/packages/gittensory-miner/lib/metrics-cli.js +++ b/packages/gittensory-miner/lib/metrics-cli.js @@ -1,5 +1,6 @@ import { renderMinerPredictionMetrics } from "@jsonbored/gittensory-engine"; import { initPredictionLedger } from "./prediction-ledger.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; // `metrics` (#4838): render the miner's prediction-calibration counters as Prometheus text-exposition to stdout, // for a scrape wrapper or cron redirect. The counters are produced by the engine's already-built @@ -33,8 +34,7 @@ function withPredictionLedger(options, run) { export function runMetrics(args, options = {}) { if (args.length > 0) { - console.error(METRICS_USAGE); - return 2; + return reportCliFailure(argsWantJson(args), METRICS_USAGE); } try { @@ -45,7 +45,6 @@ export function runMetrics(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(argsWantJson(args), describeCliError(error)); } } diff --git a/packages/gittensory-miner/lib/orb-export.js b/packages/gittensory-miner/lib/orb-export.js index bf09ba23fa..4554c50981 100644 --- a/packages/gittensory-miner/lib/orb-export.js +++ b/packages/gittensory-miner/lib/orb-export.js @@ -5,6 +5,7 @@ import { DatabaseSync } from "node:sqlite"; import { createHmac, randomBytes } from "node:crypto"; import { readPrOutcomes } from "./pr-outcome.js"; import { initEventLedger } from "./event-ledger.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; // Optional anonymized Orb telemetry export (#4277). The self-host Orb collector (src/selfhost/orb-collector.ts, // #1255) is ALWAYS-ON for a maintainer's own instance; a miner runs on a third-party contributor's laptop with a @@ -157,8 +158,7 @@ export function parseOrbExportArgs(args) { export function runOrbExportCli(args, options = {}) { const parsed = parseOrbExportArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } // Open the stores INSIDE the try so a bad config path / SQLite open failure returns 2 instead of crashing the @@ -180,8 +180,7 @@ export function runOrbExportCli(args, options = {}) { else console.log(`${batch.length} anonymized event(s)`); return 0; } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } finally { if (ownsStore) store?.close(); if (ownsLedger) eventLedger?.close(); diff --git a/packages/gittensory-miner/lib/plan-store-cli.js b/packages/gittensory-miner/lib/plan-store-cli.js index bab4d31901..caa14cc275 100644 --- a/packages/gittensory-miner/lib/plan-store-cli.js +++ b/packages/gittensory-miner/lib/plan-store-cli.js @@ -1,4 +1,5 @@ import { PLAN_STATUSES, openPlanStore } from "./plan-store.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const PLAN_LIST_USAGE = "Usage: gittensory-miner plan list [--status pending|running|completed|failed] [--json]"; @@ -101,8 +102,7 @@ function withPlanStore(options, run) { export function runPlanList(args, options = {}) { const parsed = parsePlanListArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -116,24 +116,21 @@ export function runPlanList(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runPlanShow(args, options = {}) { const parsed = parsePlanShowArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { return withPlanStore(options, (planStore) => { const plan = planStore.loadPlan(parsed.planId); if (!plan) { - console.error("plan_not_found"); - return 2; + return reportCliFailure(parsed.json, "plan_not_found"); } if (parsed.json) { console.log(JSON.stringify({ plan }, null, 2)); @@ -143,14 +140,12 @@ export function runPlanShow(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runPlanCli(subcommand, args, options = {}) { if (subcommand === "list") return runPlanList(args, options); if (subcommand === "show") return runPlanShow(args, options); - console.error(`Unknown plan subcommand: ${subcommand ?? ""}. ${PLAN_LIST_USAGE}`); - return 2; + return reportCliFailure(argsWantJson(args), `Unknown plan subcommand: ${subcommand ?? ""}. ${PLAN_LIST_USAGE}`); } diff --git a/packages/gittensory-miner/lib/portfolio-dashboard.js b/packages/gittensory-miner/lib/portfolio-dashboard.js index 05fdf61a0a..0789943cfb 100644 --- a/packages/gittensory-miner/lib/portfolio-dashboard.js +++ b/packages/gittensory-miner/lib/portfolio-dashboard.js @@ -9,6 +9,7 @@ // collector below is factored so it is directly reusable once such a channel exists. import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; const QUEUE_STATUS_KEYS = ["queued", "in_progress", "done"]; @@ -90,8 +91,7 @@ export function parsePortfolioDashboardArgs(args = []) { export function runPortfolioDashboard(args = [], options = {}) { const parsed = parsePortfolioDashboardArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } const ownsQueue = options.initPortfolioQueue === undefined; const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.js b/packages/gittensory-miner/lib/portfolio-queue-cli.js index 2c0e3d5014..3b5a1468ad 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.js +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.js @@ -1,6 +1,7 @@ import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { initPortfolioQueueManager } from "./portfolio-queue-manager.js"; import { runPortfolioDashboard } from "./portfolio-dashboard.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const QUEUE_LIST_USAGE = "Usage: gittensory-miner queue list [--repo ] [--json]"; const QUEUE_NEXT_USAGE = "Usage: gittensory-miner queue next [--json]"; @@ -156,8 +157,7 @@ function withPortfolioQueue(options, run) { export function runQueueList(args, options = {}) { const parsed = parseQueueListArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -171,16 +171,14 @@ export function runQueueList(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runQueueNext(args, options = {}) { const parsed = parseQueueNextArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -194,24 +192,21 @@ export function runQueueNext(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runQueueDone(args, options = {}) { const parsed = parseQueueDoneArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { return withPortfolioQueue(options, (portfolioQueue) => { const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier); if (!entry) { - console.error("queue_entry_not_found"); - return 2; + return reportCliFailure(parsed.json, "queue_entry_not_found"); } if (parsed.json) { console.log(JSON.stringify({ entry }, null, 2)); @@ -221,8 +216,7 @@ export function runQueueDone(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } @@ -231,16 +225,14 @@ export function runQueueDone(args, options = {}) { export function runQueueRelease(args, options = {}) { const parsed = parseQueueReleaseArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { return withPortfolioQueue(options, (portfolioQueue) => { const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier); if (!entry) { - console.error("queue_entry_not_in_progress"); - return 2; + return reportCliFailure(parsed.json, "queue_entry_not_in_progress"); } if (parsed.json) { console.log(JSON.stringify({ entry }, null, 2)); @@ -250,8 +242,7 @@ export function runQueueRelease(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } @@ -261,16 +252,14 @@ export function runQueueRelease(args, options = {}) { export function runQueueRequeue(args, options = {}) { const parsed = parseQueueRequeueArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { return withPortfolioQueue(options, (portfolioQueue) => { const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier); if (!entry) { - console.error("queue_entry_not_requeuable"); - return 2; + return reportCliFailure(parsed.json, "queue_entry_not_requeuable"); } if (parsed.json) { console.log(JSON.stringify({ entry }, null, 2)); @@ -280,8 +269,7 @@ export function runQueueRequeue(args, options = {}) { return 0; }); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } @@ -313,8 +301,7 @@ export function parseQueueClaimBatchArgs(args) { export function runQueueClaimBatch(args, options = {}) { const parsed = parseQueueClaimBatchArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the @@ -333,8 +320,7 @@ export function runQueueClaimBatch(args, options = {}) { } return 0; } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } finally { if (ownsManager) manager?.close(); } @@ -348,6 +334,5 @@ export function runQueueCli(subcommand, args, options = {}) { if (subcommand === "requeue") return runQueueRequeue(args, options); if (subcommand === "claim-batch") return runQueueClaimBatch(args, options); if (subcommand === "dashboard") return runPortfolioDashboard(args, options); - console.error(`Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); - return 2; + return reportCliFailure(argsWantJson(args), `Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); } diff --git a/packages/gittensory-miner/lib/run-state-cli.js b/packages/gittensory-miner/lib/run-state-cli.js index b579f85efc..4ed2b036c2 100644 --- a/packages/gittensory-miner/lib/run-state-cli.js +++ b/packages/gittensory-miner/lib/run-state-cli.js @@ -1,4 +1,5 @@ import { RUN_STATES, getRunState, setRunState } from "./run-state.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const STATE_GET_USAGE = "Usage: gittensory-miner state get [--json]"; const STATE_SET_USAGE = @@ -76,8 +77,7 @@ export function parseStateSetArgs(args) { export function runStateGet(args) { const parsed = parseStateGetArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -89,16 +89,14 @@ export function runStateGet(args) { } return 0; } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runStateSet(args) { const parsed = parseStateSetArgs(args); if ("error" in parsed) { - console.error(parsed.error); - return 2; + return reportCliFailure(argsWantJson(args), parsed.error); } try { @@ -110,14 +108,12 @@ export function runStateSet(args) { } return 0; } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 2; + return reportCliFailure(parsed.json, describeCliError(error)); } } export function runStateCli(subcommand, args) { if (subcommand === "get") return runStateGet(args); if (subcommand === "set") return runStateSet(args); - console.error(`Unknown state subcommand: ${subcommand ?? ""}. ${STATE_GET_USAGE}`); - return 2; + return reportCliFailure(argsWantJson(args), `Unknown state subcommand: ${subcommand ?? ""}. ${STATE_GET_USAGE}`); } diff --git a/test/unit/miner-calibration-cli.test.ts b/test/unit/miner-calibration-cli.test.ts index 33eb50b776..0f4c46aef6 100644 --- a/test/unit/miner-calibration-cli.test.ts +++ b/test/unit/miner-calibration-cli.test.ts @@ -8,6 +8,7 @@ import { initPredictionLedger, resolvePredictionLedgerDbPath, } from "../../packages/gittensory-miner/lib/prediction-ledger.js"; +import * as predictionLedger from "../../packages/gittensory-miner/lib/prediction-ledger.js"; const tempDirs: string[] = []; afterEach(() => { @@ -112,4 +113,18 @@ describe("gittensory-miner calibration CLI (#4849)", () => { expect(runCalibrationCli(["--bogus"], envForTempStores())).toBe(1); expect(String(err.mock.calls[0]?.[0])).toContain("Unknown option"); }); + + it("emits JSON when ledger open fails with --json (#4836)", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(predictionLedger, "initPredictionLedger").mockImplementation(() => { + throw new Error("corrupt_prediction_ledger"); + }); + expect(runCalibrationCli(["--json"], envForTempStores())).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "corrupt_prediction_ledger", + }); + expect(err).not.toHaveBeenCalled(); + }); }); diff --git a/test/unit/miner-claim-ledger-cli.test.ts b/test/unit/miner-claim-ledger-cli.test.ts index e73bdb3fee..7dc6ccd655 100644 --- a/test/unit/miner-claim-ledger-cli.test.ts +++ b/test/unit/miner-claim-ledger-cli.test.ts @@ -184,6 +184,17 @@ describe("gittensory-miner claim ledger CLI (#4290)", () => { }), ).toBe(2); expect(error).toHaveBeenCalledWith("claim_not_found"); + error.mockClear(); + log.mockClear(); + expect( + runClaimRelease(["acme/widgets", "404", "--json"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "claim_not_found", + }); }); it("runClaimList prints table and JSON output with repo and status filters", () => { @@ -237,6 +248,19 @@ describe("gittensory-miner claim ledger CLI (#4290)", () => { }), ).toBe(2); expect(error).toHaveBeenCalledWith("ledger_broken"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runClaimClaim(["acme/widgets", "1", "--json"], { + openClaimLedger: () => { + throw new Error("ledger_broken"); + }, + }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "ledger_broken", + }); error.mockClear(); expect( diff --git a/test/unit/miner-cli-error.test.ts b/test/unit/miner-cli-error.test.ts new file mode 100644 index 0000000000..9342270af2 --- /dev/null +++ b/test/unit/miner-cli-error.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + argsWantJson, + describeCliError, + reportCliFailure, +} from "../../packages/gittensory-miner/lib/cli-error.js"; + +describe("cli-error (#4836)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reportCliFailure logs plain text to stderr when --json is absent", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(reportCliFailure(false, "bad args")).toBe(2); + expect(err).toHaveBeenCalledWith("bad args"); + expect(log).not.toHaveBeenCalled(); + }); + + it("reportCliFailure emits parseable JSON on stdout when --json is set", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(reportCliFailure(true, "bad args", 2)).toBe(2); + expect(log).toHaveBeenCalledWith(JSON.stringify({ ok: false, error: "bad args" }, null, 2)); + expect(err).not.toHaveBeenCalled(); + }); + + it("argsWantJson detects --json in argv", () => { + expect(argsWantJson(["discover", "acme/widgets", "--json"])).toBe(true); + expect(argsWantJson(["discover", "acme/widgets"])).toBe(false); + }); + + it("describeCliError normalizes thrown values", () => { + expect(describeCliError(new Error("boom"))).toBe("boom"); + expect(describeCliError("plain")).toBe("plain"); + }); +}); diff --git a/test/unit/miner-cli-json-error-coverage.test.ts b/test/unit/miner-cli-json-error-coverage.test.ts new file mode 100644 index 0000000000..b66780f6b4 --- /dev/null +++ b/test/unit/miner-cli-json-error-coverage.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + runClaimClaim, + runClaimCli, + runClaimList, + runClaimRelease, +} from "../../packages/gittensory-miner/lib/claim-ledger-cli.js"; +import { runLedgerList } from "../../packages/gittensory-miner/lib/event-ledger-cli.js"; +import { runGovernorList } from "../../packages/gittensory-miner/lib/governor-ledger-cli.js"; +import { runLoop } from "../../packages/gittensory-miner/lib/loop-cli.js"; +import { runManagePoll } from "../../packages/gittensory-miner/lib/manage-poll.js"; +import { runPlanList, runPlanShow } from "../../packages/gittensory-miner/lib/plan-store-cli.js"; +import { + runQueueClaimBatch, + runQueueDone, + runQueueList, + runQueueNext, + runQueueRelease, + runQueueRequeue, +} from "../../packages/gittensory-miner/lib/portfolio-queue-cli.js"; + +const { getRunState, setRunState } = vi.hoisted(() => ({ + getRunState: vi.fn(), + setRunState: vi.fn(), +})); + +vi.mock("../../packages/gittensory-miner/lib/run-state.js", () => ({ + RUN_STATES: ["idle", "discovering", "planning", "preparing"], + getRunState, + setRunState, +})); + +const { runStateCli, runStateGet, runStateSet } = await import( + "../../packages/gittensory-miner/lib/run-state-cli.js" +); + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +function expectJsonError(run: () => unknown, error: string | RegExp, exitCode = 2) { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(run()).toBe(exitCode); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload).toEqual({ ok: false, error: expect.any(String) }); + if (error instanceof RegExp) expect(payload.error).toMatch(error); + else expect(payload.error).toBe(error); + expect(stderr).not.toHaveBeenCalled(); + log.mockRestore(); + stderr.mockRestore(); +} + +async function expectJsonErrorAsync( + run: () => Promise, + error: string | RegExp, + exitCode = 2, +) { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await run()).toBe(exitCode); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload).toEqual({ ok: false, error: expect.any(String) }); + if (error instanceof RegExp) expect(payload.error).toMatch(error); + else expect(payload.error).toBe(error); + expect(stderr).not.toHaveBeenCalled(); + log.mockRestore(); + stderr.mockRestore(); +} + +describe("miner CLI --json error coverage (#4836)", () => { + it("portfolio queue list/next/done/claim-batch failures", () => { + expectJsonError(() => runQueueList(["--verbose", "--json"]), /Unknown option/); + expectJsonError( + () => + runQueueList(["--json"], { + initPortfolioQueue: () => + ({ listQueue: () => { throw new Error("list_db"); }, close: () => {} }) as never, + }), + "list_db", + ); + expectJsonError( + () => + runQueueNext(["--json"], { + initPortfolioQueue: () => + ({ dequeueNext: () => { throw new Error("next_db"); }, close: () => {} }) as never, + }), + "next_db", + ); + expectJsonError( + () => + runQueueDone(["acme/a", "issue:1", "--json"], { + initPortfolioQueue: () => + ({ markDone: () => { throw new Error("done_db"); }, close: () => {} }) as never, + }), + "done_db", + ); + expectJsonError( + () => + runQueueClaimBatch(["--global-wip", "nope", "--json"], { + initPortfolioQueueManager: () => ({ claimBatch: () => [], close: () => {} }) as never, + }), + /Usage: gittensory-miner queue claim-batch/, + ); + expectJsonError( + () => + runQueueClaimBatch(["--json"], { + initPortfolioQueueManager: () => { + throw new Error("batch_db"); + }, + }), + "batch_db", + ); + expectJsonError(() => runQueueRelease(["only-one", "--json"]), /queue release/); + expectJsonError(() => runQueueRequeue(["only-one", "--json"]), /queue requeue/); + expectJsonError(() => runQueueDone(["only-one", "--json"]), /queue done/); + expectJsonError(() => runQueueNext(["--bogus", "--json"]), /Unknown option/); + expectJsonError( + () => + runQueueRelease(["acme/widgets", "issue:1", "--json"], { + initPortfolioQueue: () => + ({ reclaimStuckItem: () => { throw "raw_release_fault"; }, close: () => {} }) as never, + }), + "raw_release_fault", + ); + }); + + it("plan list/show failures", () => { + expectJsonError(() => runPlanList(["--verbose", "--json"]), /Unknown option/); + expectJsonError( + () => + runPlanList(["--json"], { + openPlanStore: () => { + throw new Error("plan_list_db"); + }, + }), + "plan_list_db", + ); + expectJsonError(() => runPlanShow(["--json"]), /Usage: gittensory-miner plan show/); + expectJsonError( + () => + runPlanShow(["plan-a", "--json"], { + openPlanStore: () => { + throw new Error("plan_show_db"); + }, + }), + "plan_show_db", + ); + }); + + it("state get/set/cli failures", () => { + expectJsonError(() => runStateGet(["--json"]), /Usage: gittensory-miner state get/); + setRunState.mockImplementation(() => { + throw new Error("set_failed"); + }); + expectJsonError(() => runStateSet(["acme/widgets", "idle", "--json"]), "set_failed"); + expectJsonError(() => runStateCli("tail", ["--json"]), /Unknown state subcommand/); + }); + + it("claim ledger runtime failures", () => { + const broken = () => { + throw new Error("ledger_broken"); + }; + expectJsonError(() => runClaimClaim(["--json"]), /Usage: gittensory-miner claim claim/); + expectJsonError(() => runClaimRelease(["acme/widgets", "--json"]), /Usage: gittensory-miner claim release/); + expectJsonError(() => runClaimList(["--status", "bogus", "--json"]), /status must be one of/); + expectJsonError( + () => runClaimClaim(["acme/widgets", "1", "--json"], { openClaimLedger: broken }), + "ledger_broken", + ); + expectJsonError( + () => + runClaimClaim(["acme/widgets", "2", "--json"], { + openClaimLedger: () => { + throw "raw_claim_fault"; + }, + }), + "raw_claim_fault", + ); + expectJsonError( + () => runClaimRelease(["acme/widgets", "1", "--json"], { openClaimLedger: broken }), + "ledger_broken", + ); + expectJsonError( + () => runClaimList(["--json"], { openClaimLedger: broken }), + "ledger_broken", + ); + expectJsonError(() => runClaimCli("peek", ["--json"]), /Unknown claim subcommand/); + }); + + it("event ledger list runtime failure", () => { + expectJsonError( + () => + runLedgerList(["--json"], { + initEventLedger: () => + ({ readEvents: () => { throw new Error("ledger_read"); }, close: () => {} }) as never, + }), + "ledger_read", + ); + }); + + it("governor list parse failure", async () => { + await expectJsonErrorAsync(() => runGovernorList(["--verbose", "--json"]), /Unknown option/); + await expectJsonErrorAsync( + () => + runGovernorList(["--json"], { + initGovernorLedger: () => { + throw new Error("gov_db"); + }, + }), + "gov_db", + ); + }); + + it("loop parse failure", async () => { + await expectJsonErrorAsync(() => runLoop(["--json"]), /Usage: gittensory-miner loop/); + }); + + it("manage poll parse failure", async () => { + await expectJsonErrorAsync(() => runManagePoll(["--json"]), /Usage: gittensory-miner manage poll/); + }); +}); diff --git a/test/unit/miner-cli-run-state.test.ts b/test/unit/miner-cli-run-state.test.ts index 564211c8c8..b00570664b 100644 --- a/test/unit/miner-cli-run-state.test.ts +++ b/test/unit/miner-cli-run-state.test.ts @@ -68,6 +68,13 @@ describe("gittensory-miner state CLI", () => { expect(runStateSet(["not-a-repo", "idle"])).toBe(2); expect(error).toHaveBeenCalledWith("Repository must be in owner/repo form."); expect(setRunState).not.toHaveBeenCalled(); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runStateSet(["not-a-repo", "idle", "--json"])).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "Repository must be in owner/repo form.", + }); }); it("runStateGet returns exit code 2 when the store read fails", () => { @@ -77,5 +84,12 @@ describe("gittensory-miner state CLI", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); expect(runStateGet(["acme/widgets"])).toBe(2); expect(error).toHaveBeenCalledWith("invalid_repo_full_name"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runStateGet(["acme/widgets", "--json"])).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "invalid_repo_full_name", + }); }); }); diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index dcf2c38359..acf16de9dc 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -82,6 +82,19 @@ describe("gittensory-miner CLI helpers", () => { ); }); + it("emits JSON for unknown commands when --json is set (#4836)", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + runCli(["mystery", "--json"], { packageName: "@jsonbored/gittensory-miner" }), + ).toBe(1); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "Unknown command: mystery. Run @jsonbored/gittensory-miner --help.", + }); + expect(error).not.toHaveBeenCalled(); + }); + it("keeps the CLI version source aligned with package metadata", async () => { const packageJson = await import( "../../packages/gittensory-miner/package.json", diff --git a/test/unit/miner-event-ledger-cli.test.ts b/test/unit/miner-event-ledger-cli.test.ts index 5f97b70857..2518a6a16b 100644 --- a/test/unit/miner-event-ledger-cli.test.ts +++ b/test/unit/miner-event-ledger-cli.test.ts @@ -132,6 +132,18 @@ describe("gittensory-miner event ledger CLI (#2290)", () => { ).toBe(2); expect(error).toHaveBeenCalledWith("since must be a non-negative integer seq cursor."); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runLedgerList(["--since", "-1", "--json"], { + initEventLedger: () => eventLedger, + }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "since must be a non-negative integer seq cursor.", + }); + error.mockClear(); expect( runLedgerList(["--since", "1.5"], { @@ -210,8 +222,18 @@ describe("gittensory-miner ledger metrics CLI (#4841)", () => { it("runLedgerMetrics rejects unexpected arguments with a usage error", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runLedgerMetrics(["--json"], { initEventLedger: () => tempLedger() })).toBe(2); + expect(error).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "Usage: gittensory-miner ledger metrics", + }); + error.mockClear(); + log.mockClear(); + expect(runLedgerMetrics(["--nope"], { initEventLedger: () => tempLedger() })).toBe(2); expect(error).toHaveBeenCalledWith("Usage: gittensory-miner ledger metrics"); + expect(log).not.toHaveBeenCalled(); }); it("runLedgerMetrics surfaces a thrown Error message and exits non-zero", () => { diff --git a/test/unit/miner-governor-ledger-cli.test.ts b/test/unit/miner-governor-ledger-cli.test.ts index 2a8d9fad90..65f4d41ecb 100644 --- a/test/unit/miner-governor-ledger-cli.test.ts +++ b/test/unit/miner-governor-ledger-cli.test.ts @@ -141,6 +141,13 @@ describe("gittensory-miner governor ledger CLI (#2328)", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); expect(await runGovernorCli("tail", [])).toBe(2); expect(String(error.mock.calls[0]?.[0])).toContain("Unknown governor subcommand"); + error.mockClear(); + log.mockClear(); + expect(await runGovernorCli("tail", ["--json"])).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Unknown governor subcommand"), + }); }); it("rejects unknown options from argv parsing", async () => { diff --git a/test/unit/miner-init-verify-token.test.ts b/test/unit/miner-init-verify-token.test.ts index 220126db62..8c736f5271 100644 --- a/test/unit/miner-init-verify-token.test.ts +++ b/test/unit/miner-init-verify-token.test.ts @@ -278,4 +278,22 @@ describe("runInit", () => { "GITHUB_TOKEN verification failed: Bad credentials", ); }); + + it("emits JSON when token verification fails with --json (#4836)", async () => { + const { env } = makeTempEnv(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + mockJsonResponse({ message: "Bad credentials" }, { status: 401 }), + ); + + const exitCode = await runInit(["--json", "--verify-token"], env); + + expect(exitCode).toBe(1); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "GITHUB_TOKEN verification failed: Bad credentials", + }); + expect(errorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/test/unit/miner-loop-cli.test.ts b/test/unit/miner-loop-cli.test.ts index 32208b0c3d..2a44713506 100644 --- a/test/unit/miner-loop-cli.test.ts +++ b/test/unit/miner-loop-cli.test.ts @@ -166,6 +166,18 @@ describe("runLoop (#5135)", () => { }); expect(exitCode).toBe(3); expect(error).toHaveBeenCalledWith(expect.stringContaining("governor state cannot be loaded")); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const jsonExit = await runLoop(["acme/widgets", "--miner-login", "alice", "--json"], { + openGovernorState: () => { + throw new Error("corrupt_governor_state_db"); + }, + }); + expect(jsonExit).toBe(3); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("governor state cannot be loaded"), + }); }); it("halts immediately on an active kill switch, before running discovery or any attempt", async () => { @@ -499,5 +511,26 @@ describe("runLoop (#5135)", () => { expect(exitCode).toBe(2); for (const spy of closeSpies) expect(spy).toHaveBeenCalledTimes(1); + + const jsonStores = tempStores(); + const jsonCloseSpies = [jsonStores.eventLedger, jsonStores.governorLedger, jsonStores.portfolioQueue, jsonStores.runState, jsonStores.governorState].map((store) => vi.spyOn(store, "close")); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const jsonExit = await runLoop(["acme/widgets", "--miner-login", "alice", "--json"], { + openGovernorState: () => jsonStores.governorState, + initEventLedger: () => jsonStores.eventLedger, + initGovernorLedger: () => jsonStores.governorLedger, + initPortfolioQueue: () => jsonStores.portfolioQueue, + initRunStateStore: () => jsonStores.runState, + runDiscover: async () => { + throw new Error("network_unreachable"); + }, + ...readyLoopOptions(), + }); + expect(jsonExit).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "network_unreachable", + }); + for (const spy of jsonCloseSpies) expect(spy).toHaveBeenCalledTimes(1); }); }); diff --git a/test/unit/miner-manage-poll.test.ts b/test/unit/miner-manage-poll.test.ts index 7c7a27ef1d..6d1b428adb 100644 --- a/test/unit/miner-manage-poll.test.ts +++ b/test/unit/miner-manage-poll.test.ts @@ -187,5 +187,18 @@ describe("gittensory-miner manage poll (#2323/#2325)", () => { }), ).toBe(2); expect(error).toHaveBeenCalledWith("github_404: not found"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + await runManagePoll(["acme/widgets", "9", "--json"], { + initPortfolioQueue: () => portfolioQueue, + initEventLedger: () => eventLedger, + pollCheckRuns: vi.fn().mockRejectedValue(new Error("github_404: not found")), + }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "github_404: not found", + }); }); }); diff --git a/test/unit/miner-metrics-cli.test.ts b/test/unit/miner-metrics-cli.test.ts index 90423df7d2..18730de2e7 100644 --- a/test/unit/miner-metrics-cli.test.ts +++ b/test/unit/miner-metrics-cli.test.ts @@ -85,8 +85,18 @@ describe("gittensory-miner metrics CLI (#4838)", () => { it("runMetrics rejects unexpected arguments with a usage error", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runMetrics(["--json"], { initPredictionLedger: () => tempLedger() })).toBe(2); + expect(error).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "Usage: gittensory-miner metrics", + }); + error.mockClear(); + log.mockClear(); + expect(runMetrics(["--nope"], { initPredictionLedger: () => tempLedger() })).toBe(2); expect(error).toHaveBeenCalledWith("Usage: gittensory-miner metrics"); + expect(log).not.toHaveBeenCalled(); }); it("runMetrics surfaces a thrown Error message and exits non-zero", () => { diff --git a/test/unit/miner-plan-store-cli.test.ts b/test/unit/miner-plan-store-cli.test.ts index 851bb88bea..0bf5195874 100644 --- a/test/unit/miner-plan-store-cli.test.ts +++ b/test/unit/miner-plan-store-cli.test.ts @@ -127,6 +127,17 @@ describe("gittensory-miner plan store CLI (#2318)", () => { }), ).toBe(2); expect(error).toHaveBeenCalledWith("plan_not_found"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runPlanShow(["missing", "--json"], { + openPlanStore: () => planStore, + }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "plan_not_found", + }); }); it("runPlanCli dispatches list and show subcommands", () => { @@ -145,5 +156,12 @@ describe("gittensory-miner plan store CLI (#2318)", () => { expect(runPlanCli("save", [])).toBe(2); expect(runPlanList(["--verbose"])).toBe(2); expect(String(error.mock.calls[0]?.[0])).toContain("Unknown plan subcommand"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runPlanCli("save", ["--json"])).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Unknown plan subcommand"), + }); }); }); diff --git a/test/unit/miner-portfolio-dashboard.test.ts b/test/unit/miner-portfolio-dashboard.test.ts index f55d11914b..b552c15767 100644 --- a/test/unit/miner-portfolio-dashboard.test.ts +++ b/test/unit/miner-portfolio-dashboard.test.ts @@ -86,5 +86,13 @@ describe("runPortfolioDashboard (#4287)", () => { const err = vi.spyOn(console, "error").mockImplementation(() => {}); expect(runPortfolioDashboard(["--bad"], { initPortfolioQueue: () => store })).toBe(2); expect(String(err.mock.calls[0]?.[0])).toContain("Unknown option"); + log.mockClear(); + err.mockClear(); + expect(runPortfolioDashboard(["--bad", "--json"], { initPortfolioQueue: () => store })).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Unknown option"), + }); + expect(err).not.toHaveBeenCalled(); }); }); diff --git a/test/unit/miner-portfolio-queue-cli.test.ts b/test/unit/miner-portfolio-queue-cli.test.ts index 329947c865..72171f6e74 100644 --- a/test/unit/miner-portfolio-queue-cli.test.ts +++ b/test/unit/miner-portfolio-queue-cli.test.ts @@ -148,6 +148,18 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { }), ).toBe(2); expect(error).toHaveBeenCalledWith("queue_entry_not_found"); + error.mockClear(); + log.mockClear(); + expect( + runQueueDone(["acme/widgets", "issue:404", "--json"], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "queue_entry_not_found", + }); + expect(error).not.toHaveBeenCalled(); }); it("runQueueCli dispatches list, next, and done subcommands", () => { @@ -167,6 +179,14 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(runQueueCli("peek", [])).toBe(2); expect(runQueueList(["--verbose"])).toBe(2); expect(String(error.mock.calls[0]?.[0])).toContain("Unknown queue subcommand"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runQueueCli("peek", ["--json"])).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Unknown queue subcommand"), + }); + expect(error).not.toHaveBeenCalled(); }); describe("release / requeue escape hatch (#4828)", () => { @@ -218,6 +238,15 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(runQueueRelease(["acme/widgets", "issue:8"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); expect(error).toHaveBeenCalledWith("queue_entry_not_in_progress"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runQueueRelease(["acme/widgets", "issue:8", "--json"], { initPortfolioQueue: () => portfolioQueue }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "queue_entry_not_in_progress", + }); }); it("requeue puts a COMPLETED (done) item back on the queue, keeping its position", () => { @@ -243,6 +272,15 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(error).toHaveBeenCalledWith("queue_entry_not_requeuable"); // Absent item too. expect(runQueueRequeue(["acme/widgets", "issue:404"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runQueueRequeue(["acme/widgets", "issue:10", "--json"], { initPortfolioQueue: () => portfolioQueue }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "queue_entry_not_requeuable", + }); }); it("the shared parser rejects a bad option, a malformed repo, and an empty identifier", () => { @@ -274,6 +312,15 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(runQueueRelease(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingStore })).toBe(2); expect(runQueueRequeue(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingStore })).toBe(2); expect(error).toHaveBeenCalledWith("db_locked"); + error.mockClear(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runQueueRelease(["acme/widgets", "issue:1", "--json"], { initPortfolioQueue: () => throwingStore }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "db_locked", + }); // A thrown non-Error is stringified rather than crashing (the String(error) fallback branch). const throwingNonError = {