Normalize JSON and JSONL output

This commit is contained in:
Eric Wendland 2026-07-18 17:15:36 +02:00
commit 116a431a12
5 changed files with 276 additions and 191 deletions

View file

@ -370,12 +370,18 @@ pub struct Cli {
help = "Use DIR as geth home instead of GETH_HOME or the OS data directory"
)]
pub home: Option<PathBuf>,
#[arg(long, global = true, help = "Print machine-readable JSON output")]
#[arg(
long,
global = true,
conflicts_with = "jsonl",
help = "Print one pretty machine-readable JSON document"
)]
pub json: bool,
#[arg(
long,
global = true,
help = "Print newline-delimited JSON output for streaming commands"
conflicts_with = "json",
help = "Print each result as one compact newline-delimited JSON object"
)]
pub jsonl: bool,
#[command(subcommand)]
@ -1585,14 +1591,50 @@ pub enum SshRevocationCommand {
#[derive(Debug, Args)]
pub struct EmptyArgs {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OutputMode {
Human,
Json,
Jsonl,
}
impl OutputMode {
fn from_flags(json: bool, jsonl: bool) -> Self {
if jsonl {
Self::Jsonl
} else if json {
Self::Json
} else {
Self::Human
}
}
fn is_machine(self) -> bool {
self != Self::Human
}
fn encode(self, value: &serde_json::Value) -> Result<String> {
match self {
Self::Json => serde_json::to_string_pretty(value).map_err(Into::into),
Self::Jsonl => serde_json::to_string(value).map_err(Into::into),
Self::Human => bail!("internal error: attempted JSON encoding in human output mode"),
}
}
fn print(self, value: &serde_json::Value) -> Result<()> {
println!("{}", self.encode(value)?);
Ok(())
}
}
pub async fn run() -> Result<()> {
let matches = documented_cli_command().get_matches();
let cli = Cli::from_arg_matches(&matches).context("parse geth command")?;
let json = cli.json || cli.jsonl;
let output = OutputMode::from_flags(cli.json, cli.jsonl);
match run_inner(cli).await {
Ok(()) => Ok(()),
Err(error) if json => {
print_json_error(&error)?;
Err(error) if output.is_machine() => {
print_json_error(&error, output)?;
std::process::exit(1);
}
Err(error) => Err(error),
@ -1781,7 +1823,11 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
}
async fn run_inner(cli: Cli) -> Result<()> {
let output = OutputMode::from_flags(cli.json, cli.jsonl);
if let Command::Completions { shell } = &cli.command {
if output.is_machine() {
bail!("completion scripts cannot be combined with --json or --jsonl");
}
print_completions(*shell);
return Ok(());
}
@ -1805,10 +1851,10 @@ async fn run_inner(cli: Cli) -> Result<()> {
.context("resolve geth paths")?;
match cli.command {
Command::Guide { topic } => {
print_guide(topic, cli.json || cli.jsonl)?;
print_guide(topic, output)?;
}
Command::Config { command } => {
run_config_command(&paths, command, cli.json || cli.jsonl)?;
run_config_command(&paths, command, output)?;
}
Command::Init {
admin_key,
@ -1828,18 +1874,35 @@ async fn run_inner(cli: Cli) -> Result<()> {
},
)
.context("initialize geth node")?;
println!("initialized geth home: {}", node.paths.home().display());
println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "initialized",
"home": node.paths.home(),
"agent_id": node.agent_id,
"node_id": node.node_id,
}))?;
} else {
println!("initialized geth home: {}", node.paths.home().display());
println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id);
}
}
Command::Daemon {
command: DaemonCommand::Run { ephemeral: true },
} => {
run_ephemeral_daemon(cli.json || cli.jsonl).await?;
run_ephemeral_daemon(output).await?;
}
Command::Daemon {
command: DaemonCommand::Run { ephemeral: false },
} => {
if output.is_machine() {
output.print(&serde_json::json!({
"type": "daemon-starting",
"home": paths.home(),
"control_endpoint": paths.control_endpoint(),
}))?;
stdout().flush().context("flush daemon startup details")?;
}
geth_node::run_daemon(paths)
.await
.context("run geth daemon")?;
@ -1878,7 +1941,7 @@ async fn run_inner(cli: Cli) -> Result<()> {
.await?,
)
};
print_daemon_install_report(&report, readiness.as_ref(), cli.json || cli.jsonl)?;
print_daemon_install_report(&report, readiness.as_ref(), output)?;
if readiness.as_ref().is_some_and(|readiness| !readiness.ready) {
std::process::exit(1);
}
@ -1888,21 +1951,21 @@ async fn run_inner(cli: Cli) -> Result<()> {
} => {
let report = run_service_command(&paths, ServiceCommand::Start { manager })
.context("start geth user service; install it first with `geth daemon install`")?;
print_service_report(&report, cli.json || cli.jsonl)?;
print_service_report(&report, output)?;
}
Command::Daemon {
command: DaemonCommand::Stop { manager },
} => {
let report = run_service_command(&paths, ServiceCommand::Stop { manager })
.context("stop geth user service")?;
print_service_report(&report, cli.json || cli.jsonl)?;
print_service_report(&report, output)?;
}
Command::Daemon {
command: DaemonCommand::Status { manager },
} => {
let report = run_service_command(&paths, ServiceCommand::Status { manager })
.context("query geth user service; install it with `geth daemon install`")?;
print_service_report(&report, cli.json || cli.jsonl)?;
print_service_report(&report, output)?;
}
Command::Daemon {
command:
@ -1912,7 +1975,7 @@ async fn run_inner(cli: Cli) -> Result<()> {
follow,
},
} => {
if follow && (cli.json || cli.jsonl) {
if follow && output.is_machine() {
bail!("--follow cannot be combined with --json or --jsonl");
}
let report = geth_node::service::logs_user_service(
@ -1922,7 +1985,7 @@ async fn run_inner(cli: Cli) -> Result<()> {
follow,
)
.context("read geth user-service logs")?;
print_service_logs_report(&report, cli.json || cli.jsonl)?;
print_service_logs_report(&report, output)?;
if matches!(
report.state,
ServiceLogState::NotInstalled | ServiceLogState::Unavailable
@ -1935,31 +1998,30 @@ async fn run_inner(cli: Cli) -> Result<()> {
} => {
let report = run_service_command(&paths, ServiceCommand::Uninstall { manager })
.context("uninstall geth user service")?;
print_service_report(&report, cli.json || cli.jsonl)?;
print_service_report(&report, output)?;
}
Command::Daemon {
command: DaemonCommand::Service { command },
} => {
let report =
run_service_command(&paths, command).context("manage geth user service")?;
print_service_report(&report, cli.json || cli.jsonl)?;
print_service_report(&report, output)?;
}
Command::Wait { command } => {
let report = run_wait_command(&paths, command).await?;
print_wait_report(&report, cli.json || cli.jsonl)?;
print_wait_report(&report, output)?;
if !report.ready {
std::process::exit(1);
}
}
Command::Backup { command } => {
run_backup_command(&paths, command, cli.json || cli.jsonl)
.context("run backup command")?;
run_backup_command(&paths, command, output).context("run backup command")?;
}
Command::Doctor => {
let report = geth_node::doctor::run_doctor(&paths)
.await
.context("run doctor")?;
print_doctor_report(&report, cli.json || cli.jsonl)?;
print_doctor_report(&report, output)?;
if !report.ok {
std::process::exit(1);
}
@ -1970,7 +2032,7 @@ async fn run_inner(cli: Cli) -> Result<()> {
node,
bearer_secret,
},
} if !cli.json && !cli.jsonl => {
} if output == OutputMode::Human => {
geth_node::stream_ssh_proxy(&paths, node, bearer_secret)
.await
.context("stream SSH proxy through geth daemon")?;
@ -1983,7 +2045,7 @@ async fn run_inner(cli: Cli) -> Result<()> {
target,
bearer_secret,
},
} if !cli.json && !cli.jsonl => {
} if output == OutputMode::Human => {
println!("forwarding tcp {listen} -> {node}:{target}");
geth_node::run_tcp_forward(&paths, listen, node, target, bearer_secret)
.await
@ -1997,7 +2059,7 @@ async fn run_inner(cli: Cli) -> Result<()> {
target,
bearer_secret,
},
} if !cli.json && !cli.jsonl => {
} if output == OutputMode::Human => {
println!(
"forwarding unix {} -> {node}:{}",
listen.display(),
@ -2017,25 +2079,22 @@ async fn run_inner(cli: Cli) -> Result<()> {
paths.socket_path().display()
)
})?;
print_response(response, cli.json || cli.jsonl)?;
print_response(response, output)?;
}
}
Ok(())
}
fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) -> Result<()> {
fn run_config_command(paths: &GethPaths, command: ConfigCommand, output: OutputMode) -> Result<()> {
let path = paths.config_file();
match command {
ConfigCommand::Path => {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config-path",
"path": path,
"exists": path.exists(),
}))?
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "config-path",
"path": path,
"exists": path.exists(),
}))?;
} else {
println!("{}", path.display());
}
@ -2050,17 +2109,14 @@ fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) ->
};
let config = GethConfig::parse(&text)
.with_context(|| format!("validate config at {}", path.display()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config",
"path": path,
"source": if exists { "file" } else { "built-in-defaults" },
"raw": text,
"effective": config_json(&config),
}))?
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "config",
"path": path,
"source": if exists { "file" } else { "built-in-defaults" },
"raw": text,
"effective": config_json(&config),
}))?;
} else {
println!("# path: {}", path.display());
if !exists {
@ -2076,17 +2132,14 @@ fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) ->
let exists = path.exists();
let config = GethConfig::load(&path)
.with_context(|| format!("validate config at {}", path.display()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config-validation",
"path": path,
"valid": true,
"source": if exists { "file" } else { "built-in-defaults" },
"effective": config_json(&config),
}))?
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "config-validation",
"path": path,
"valid": true,
"source": if exists { "file" } else { "built-in-defaults" },
"effective": config_json(&config),
}))?;
} else if exists {
println!("valid config: {}", path.display());
} else {
@ -2100,18 +2153,15 @@ fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) ->
let key = ConfigKey::from(key);
let config = GethConfig::set(&path, key, &value)
.with_context(|| format!("set {} in {}", key.as_str(), path.display()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config-updated",
"path": path,
"key": key.as_str(),
"value": value,
"restart_required": true,
"effective": config_json(&config),
}))?
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "config-updated",
"path": path,
"key": key.as_str(),
"value": value,
"restart_required": true,
"effective": config_json(&config),
}))?;
} else {
println!("updated {} in {}", key.as_str(), path.display());
println!("restart the daemon for the change to take effect");
@ -2141,19 +2191,16 @@ fn config_json(config: &GethConfig) -> serde_json::Value {
})
}
async fn run_ephemeral_daemon(json: bool) -> Result<()> {
async fn run_ephemeral_daemon(output: OutputMode) -> Result<()> {
let (home, paths, node) = create_ephemeral_node()?;
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"type": "ephemeral-daemon-starting",
"home": paths.home(),
"node_id": node.node_id,
"control_command": format!("geth --home {} status", paths.home().display()),
"cleanup": "state is removed after normal daemon shutdown",
}))?
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "ephemeral-daemon-starting",
"home": paths.home(),
"node_id": node.node_id,
"control_command": format!("geth --home {} status", paths.home().display()),
"cleanup": "state is removed after normal daemon shutdown",
}))?;
} else {
println!("starting ephemeral geth daemon");
println!("home: {}", paths.home().display());
@ -2181,21 +2228,17 @@ fn create_ephemeral_node() -> Result<(tempfile::TempDir, GethPaths, geth_node::L
Ok((home, paths, node))
}
fn print_json_error(error: &anyhow::Error) -> Result<()> {
fn print_json_error(error: &anyhow::Error, output: OutputMode) -> 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(())
let hint = json_error_hint(&message);
output.print(&serde_json::json!({
"type": "error",
"code": json_error_code(&detail),
"message": message,
"detail": detail,
"hint": hint,
}))
}
fn json_error_code(detail: &str) -> &'static str {
@ -2224,7 +2267,7 @@ fn json_error_hint(detail: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn print_guide(topic: Option<GuideTopic>, json: bool) -> Result<()> {
fn print_guide(topic: Option<GuideTopic>, output: OutputMode) -> Result<()> {
let (name, body) = match topic {
None => ("index", GUIDE_INDEX),
Some(GuideTopic::Quickstart) => ("quickstart", GUIDE_QUICKSTART),
@ -2237,14 +2280,12 @@ fn print_guide(topic: Option<GuideTopic>, json: bool) -> Result<()> {
Some(GuideTopic::Completions) => ("completions", GUIDE_COMPLETIONS),
Some(GuideTopic::SmokeTest) => ("smoke-test", GUIDE_SMOKE_TEST),
};
if json {
println!(
"{}",
serde_json::json!({
"topic": name,
"body": body,
})
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "guide",
"topic": name,
"body": body,
}))?;
} else {
print!("{body}");
}
@ -3060,21 +3101,18 @@ struct WaitReport {
reason: String,
}
fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) -> Result<()> {
fn run_backup_command(paths: &GethPaths, command: BackupCommand, output: OutputMode) -> Result<()> {
match command {
BackupCommand::Create { out } => {
let report = geth_node::backup::create_backup(paths, &out)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "backup-created",
"backup_dir": report.backup_dir,
"files_copied": report.files_copied,
"bytes_copied": report.bytes_copied,
"manifest": report.manifest,
}))?
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "backup-created",
"backup_dir": report.backup_dir,
"files_copied": report.files_copied,
"bytes_copied": report.bytes_copied,
"manifest": report.manifest,
}))?;
} else {
println!("backup: {}", report.backup_dir.display());
println!("files_copied: {}", report.files_copied);
@ -3093,18 +3131,15 @@ fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) ->
target_home,
} => {
let report = geth_node::backup::restore_backup(&backup_dir, &target_home)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "backup-restored",
"backup_dir": report.backup_dir,
"target_home": report.target_home,
"files_restored": report.files_restored,
"bytes_restored": report.bytes_restored,
"manifest": report.manifest,
}))?
);
if output.is_machine() {
output.print(&serde_json::json!({
"type": "backup-restored",
"backup_dir": report.backup_dir,
"target_home": report.target_home,
"files_restored": report.files_restored,
"bytes_restored": report.bytes_restored,
"manifest": report.manifest,
}))?;
} else {
println!("restored: {}", report.target_home.display());
println!("backup: {}", report.backup_dir.display());
@ -3120,16 +3155,13 @@ fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) ->
Ok(())
}
fn print_doctor_report(report: &geth_node::doctor::DoctorReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "doctor",
"ok": report.ok,
"checks": report.checks,
}))?
);
fn print_doctor_report(report: &geth_node::doctor::DoctorReport, output: OutputMode) -> Result<()> {
if output.is_machine() {
output.print(&serde_json::json!({
"type": "doctor",
"ok": report.ok,
"checks": report.checks,
}))?;
return Ok(());
}
@ -3279,19 +3311,16 @@ where
}
}
fn print_wait_report(report: &WaitReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "wait",
"target": report.target,
"ready": report.ready,
"elapsed_ms": report.elapsed_ms,
"attempts": report.attempts,
"reason": report.reason,
}))?
);
fn print_wait_report(report: &WaitReport, output: OutputMode) -> Result<()> {
if output.is_machine() {
output.print(&serde_json::json!({
"type": "wait",
"target": report.target,
"ready": report.ready,
"elapsed_ms": report.elapsed_ms,
"attempts": report.attempts,
"reason": report.reason,
}))?;
return Ok(());
}
println!("target: {}", report.target);
@ -3451,9 +3480,12 @@ fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport
println!("note: {}", report.note);
}
fn print_response(response: ControlResponse, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&response)?);
fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
if let ControlResponse::Error { message } = &response {
bail!(message.clone());
}
if output.is_machine() {
output.print(&serde_json::to_value(&response)?)?;
return Ok(());
}
match response {
@ -5104,21 +5136,18 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
fn print_daemon_install_report(
report: &ServiceReport,
readiness: Option<&WaitReport>,
json: bool,
output: OutputMode,
) -> Result<()> {
if json {
if output.is_machine() {
let readiness = readiness.map(wait_report_json);
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "daemon-install",
"service": service_report_json(report),
"readiness": readiness,
}))?
);
output.print(&serde_json::json!({
"type": "daemon-install",
"service": service_report_json(report),
"readiness": readiness,
}))?;
return Ok(());
}
print_service_report(report, false)?;
print_service_report(report, OutputMode::Human)?;
if let Some(readiness) = readiness {
println!("readiness:");
println!(" ready: {}", readiness.ready);
@ -5143,6 +5172,7 @@ fn wait_report_json(report: &WaitReport) -> serde_json::Value {
fn service_report_json(report: &ServiceReport) -> serde_json::Value {
serde_json::json!({
"type": "service",
"manager": report.manager.to_string(),
"action": report.action.as_str(),
"service_name": report.service_name,
@ -5154,12 +5184,9 @@ fn service_report_json(report: &ServiceReport) -> serde_json::Value {
})
}
fn print_service_report(report: &ServiceReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(&service_report_json(report))?
);
fn print_service_report(report: &ServiceReport, output: OutputMode) -> Result<()> {
if output.is_machine() {
output.print(&service_report_json(report))?;
return Ok(());
}
@ -5186,21 +5213,18 @@ fn print_service_report(report: &ServiceReport, json: bool) -> Result<()> {
Ok(())
}
fn print_service_logs_report(report: &ServiceLogsReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "service-logs",
"manager": report.manager.to_string(),
"service_name": report.service_name,
"state": report.state.as_str(),
"sources": report.sources,
"command": report.command,
"lines": report.lines,
"note": report.note,
}))?
);
fn print_service_logs_report(report: &ServiceLogsReport, output: OutputMode) -> Result<()> {
if output.is_machine() {
output.print(&serde_json::json!({
"type": "service-logs",
"manager": report.manager.to_string(),
"service_name": report.service_name,
"state": report.state.as_str(),
"sources": report.sources,
"command": report.command,
"lines": report.lines,
"note": report.note,
}))?;
return Ok(());
}
println!("service: {}", report.service_name);
@ -5413,7 +5437,7 @@ mod tests {
key: ConfigKeyArg::SyncLiveSyncIntervalMs,
value: "500".to_owned(),
},
false,
OutputMode::Human,
)
.expect("set config");
let config = GethConfig::load(&paths.config_file()).expect("load config");
@ -5446,6 +5470,23 @@ mod tests {
));
}
#[test]
fn json_and_jsonl_have_distinct_single_document_contracts() {
let value = serde_json::json!({
"type": "contract-test",
"nested": { "ok": true },
});
let pretty = OutputMode::Json.encode(&value).expect("pretty json");
let jsonl = OutputMode::Jsonl.encode(&value).expect("jsonl");
assert!(pretty.contains('\n'));
assert!(!jsonl.contains('\n'));
assert_eq!(
serde_json::from_str::<serde_json::Value>(&jsonl).expect("decode jsonl"),
value
);
assert!(Cli::try_parse_from(["geth", "status", "--json", "--jsonl"]).is_err());
}
#[tokio::test]
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
let parsed = Cli::try_parse_from([