diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 47cfdb9..55d0f2e 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -1227,6 +1227,18 @@ pub struct EmptyArgs {} pub async fn run() -> Result<()> { let cli = Cli::parse(); + let json = cli.json || cli.jsonl; + match run_inner(cli).await { + Ok(()) => Ok(()), + Err(error) if json => { + print_json_error(&error)?; + std::process::exit(1); + } + Err(error) => Err(error), + } +} + +async fn run_inner(cli: Cli) -> Result<()> { if let Command::Completions { shell } = &cli.command { print_completions(*shell); return Ok(()); @@ -1328,6 +1340,47 @@ pub async fn run() -> Result<()> { Ok(()) } +fn print_json_error(error: &anyhow::Error) -> Result<()> { + let message = error.to_string(); + let detail = format!("{error:#}"); + let hint = json_error_hint(&detail); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "type": "error", + "code": json_error_code(&detail), + "message": message, + "detail": detail, + "hint": hint, + }))? + ); + Ok(()) +} + +fn json_error_code(detail: &str) -> &'static str { + let lower = detail.to_ascii_lowercase(); + if lower.contains("connect to daemon") || lower.contains("connection refused") { + "daemon_unavailable" + } else if lower.contains("unauthorized") || lower.contains("missing grant") { + "unauthorized" + } else if lower.contains("peer candidate not found") { + "peer_not_found" + } else if lower.contains("resource not found") { + "resource_not_found" + } else if lower.contains("invalid") { + "invalid_input" + } else { + "command_failed" + } +} + +fn json_error_hint(detail: &str) -> Option { + detail + .lines() + .find_map(|line| line.strip_prefix("next: ")) + .map(ToOwned::to_owned) +} + fn print_guide(topic: Option, json: bool) -> Result<()> { let (name, body) = match topic { None => ("index", GUIDE_INDEX), @@ -3931,3 +3984,30 @@ fn shell_quote_command(command: &[String]) -> String { .collect::>() .join(" ") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_error_classification_is_stable_for_common_failures() { + assert_eq!( + json_error_code("connect to daemon at /tmp/geth.sock: No such file or directory"), + "daemon_unavailable" + ); + assert_eq!( + json_error_code("unauthorized: missing grant"), + "unauthorized" + ); + assert_eq!( + json_error_code("peer candidate not found: node:missing"), + "peer_not_found" + ); + assert_eq!( + json_error_hint( + "peer candidate not found: node:missing\nnext: import a peer card with `geth peer import `", + ), + Some("import a peer card with `geth peer import `".to_owned()) + ); + } +} diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 3778f86..a5b98a8 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -344,6 +344,24 @@ fn stable_json_output_matches_fixtures() { ); } +#[test] +fn json_errors_include_stable_code_for_common_failures() { + let home = tempfile::tempdir().expect("tempdir"); + let output = run_geth(home.path(), &["--json", "status"]); + + assert!(!output.status.success()); + let error = serde_json::from_slice::(&output.stdout) + .expect("decode json error output"); + assert_eq!(error["type"], "error"); + assert_eq!(error["code"], "daemon_unavailable"); + assert!( + error["message"] + .as_str() + .expect("error message") + .contains("connect to daemon") + ); +} + #[test] fn peer_ping_uses_daemon_owned_iroh_endpoint() { if skip_iroh_integration_tests() { diff --git a/docs/compatibility.md b/docs/compatibility.md index cfaafe0..dd1dab3 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -36,6 +36,12 @@ explicit migration expectation before scripts depend on them. top-level response variant name and existing field names keep their meaning within a major release. +Execution failures in `--json` or `--jsonl` mode return a stable JSON error +object on stdout with `type: "error"`, a machine-readable `code`, a human +`message`, full `detail`, and an optional `hint` derived from operator +recovery output. Common codes include `daemon_unavailable`, `unauthorized`, +`peer_not_found`, `resource_not_found`, `invalid_input`, and `command_failed`. + Backward-compatible JSON changes include: - Adding nullable or optional fields. diff --git a/docs/production-readiness-roadmap.md b/docs/production-readiness-roadmap.md index d391b60..2572e42 100644 --- a/docs/production-readiness-roadmap.md +++ b/docs/production-readiness-roadmap.md @@ -236,11 +236,11 @@ Goal: make convergence and failure behavior predictable enough for automation. Goal: make `geth` ergonomic and stable as a base layer for custom automation. -- `[ ]` Stabilize JSON errors. +- `[x]` Stabilize JSON errors. Acceptance criteria: - - `[ ]` Common failures include stable machine-readable error codes. - - `[ ]` Human errors still include next-step recovery hints. - - `[ ]` Tests assert both code and operator-facing hint for representative + - `[x]` Common failures include stable machine-readable error codes. + - `[x]` Human errors still include next-step recovery hints. + - `[x]` Tests assert both code and operator-facing hint for representative failures. - `[ ]` Add wait commands for automation.