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

@ -72,6 +72,13 @@ Most non-daemon commands talk to the daemon through a local Unix socket at
durability settings, Iroh endpoint/relay/discovery state, and native backend durability settings, Iroh endpoint/relay/discovery state, and native backend
health for automation. health for automation.
For automation, `--json` emits one pretty JSON document and `--jsonl` emits one
compact physical line per result. They are mutually exclusive; both success
and error objects carry a stable top-level `type`, and machine-mode failures
exit nonzero without mixing human prose into stdout. The detailed compatibility
contract and examples are in [`docs/compatibility.md`](docs/compatibility.md)
and [`docs/automation-examples.md`](docs/automation-examples.md).
`geth init --admin-key <public-key> --signing-key <private-key> --node-name `geth init --admin-key <public-key> --signing-key <private-key> --node-name
<name>` records an owner/admin keychain, the local user/device/node binding, and <name>` records an owner/admin keychain, the local user/device/node binding, and
signs canonical keychain payloads through `ssh-keygen -Y sign` using the signs canonical keychain payloads through `ssh-keygen -Y sign` using the

View file

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

View file

@ -4,6 +4,12 @@ These examples are intended as starting points for local scripts and
infrastructure automation. They assume the single `geth` executable is on infrastructure automation. They assume the single `geth` executable is on
`PATH`. `PATH`.
Use `--json` when one process invocation produces one result that will be
parsed as a complete document. Use `--jsonl` for line-oriented shell pipelines:
each current command writes one compact object on one physical line. The flags
are mutually exclusive, and failures use the same `type: "error"` envelope and
return a nonzero exit status in either mode.
## Shell ## Shell
Initialize a home, start the daemon in a user-owned process, wait for control, Initialize a home, start the daemon in a user-owned process, wait for control,
@ -25,6 +31,13 @@ geth doctor --json
geth status --json geth status --json
``` ```
For a compact line suitable for an append-only log or `jq -c` pipeline:
```sh
geth status --jsonl >>geth-status.jsonl
tail -n 1 geth-status.jsonl | jq -r '.type'
```
Create a backup into a separate directory and validate that it can restore to a Create a backup into a separate directory and validate that it can restore to a
new home: new home:

View file

@ -32,15 +32,28 @@ explicit migration expectation before scripts depend on them.
## JSON Output ## JSON Output
`--json` output is the preferred script contract. For stable commands, the `--json` and `--jsonl` are mutually exclusive machine-output modes:
- `--json` writes exactly one pretty-printed JSON document for the command
result. Embedded newlines are formatting whitespace, so consumers should
parse the complete stdout stream as one document.
- `--jsonl` writes each result as one compact JSON object followed by a
newline. Current non-streaming commands emit one physical line; a future
streaming command may emit multiple records, one complete object per line.
Machine output does not mix operator prose into stdout. Successful direct CLI
workflows and local-control responses include a stable top-level `type` field;
type and action names use lowercase kebab-case. For stable commands, the
top-level response variant name and existing field names keep their meaning top-level response variant name and existing field names keep their meaning
within a major release. within a major release.
Execution failures in `--json` or `--jsonl` mode return a stable JSON error Execution failures in `--json` or `--jsonl` mode return a stable JSON error
object on stdout with `type: "error"`, a machine-readable `code`, a human object on stdout with `type: "error"`, a machine-readable `code`, a human
`message`, full `detail`, and an optional `hint` derived from operator `message`, full `detail`, and an optional `hint` derived from operator
recovery output. Common codes include `daemon_unavailable`, `unauthorized`, recovery output, and exit nonzero. The envelope is pretty-printed in `--json`
`peer_not_found`, `resource_not_found`, `invalid_input`, and `command_failed`. mode and occupies exactly one physical line in `--jsonl` mode. Common codes
include `daemon_unavailable`, `unauthorized`, `peer_not_found`,
`resource_not_found`, `invalid_input`, and `command_failed`.
Backward-compatible JSON changes include: Backward-compatible JSON changes include:

View file

@ -121,6 +121,17 @@ For deployment-readiness work that cuts across feature areas, see
- `[x]` Tests cover transient path detection/copying, file-log states, and - `[x]` Tests cover transient path detection/copying, file-log states, and
user-scoped generated definitions. user-scoped generated definitions.
- `[x]` Normalize machine-readable CLI output.
Acceptance criteria:
- `[x]` `--json` emits one pretty JSON document and `--jsonl` emits each
result as one compact physical line; the flags are mutually exclusive.
- `[x]` Direct CLI results, local-control responses, and service reports use
stable top-level `type` fields without mixing operator prose into stdout.
- `[x]` Machine-mode failures use the stable error envelope, include a
focused recovery hint when available, and exit nonzero.
- `[x]` Tests and automation documentation define the single-document and
line-oriented contracts.
- `[ ]` Publish copy-paste installation entrypoints for release artifacts. - `[ ]` Publish copy-paste installation entrypoints for release artifacts.
Acceptance criteria: Acceptance criteria:
- `[ ]` Linux, macOS, and Windows installation instructions verify artifact - `[ ]` Linux, macOS, and Windows installation instructions verify artifact