Normalize JSON and JSONL output
This commit is contained in:
parent
3c84713472
commit
116a431a12
5 changed files with 276 additions and 191 deletions
|
|
@ -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
|
||||
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
|
||||
<name>` records an owner/admin keychain, the local user/device/node binding, and
|
||||
signs canonical keychain payloads through `ssh-keygen -Y sign` using the
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`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
|
||||
|
||||
Initialize a home, start the daemon in a user-owned process, wait for control,
|
||||
|
|
@ -25,6 +31,13 @@ geth doctor --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
|
||||
new home:
|
||||
|
||||
|
|
|
|||
|
|
@ -32,15 +32,28 @@ explicit migration expectation before scripts depend on them.
|
|||
|
||||
## 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
|
||||
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`.
|
||||
recovery output, and exit nonzero. The envelope is pretty-printed in `--json`
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
Acceptance criteria:
|
||||
- `[ ]` Linux, macOS, and Windows installation instructions verify artifact
|
||||
|
|
|
|||
Loading…
Reference in a new issue