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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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")?;
|
||||||
|
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!("initialized geth home: {}", node.paths.home().display());
|
||||||
println!("agent: {}", node.agent_id);
|
println!("agent: {}", node.agent_id);
|
||||||
println!("node: {}", node.node_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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "config-path",
|
"type": "config-path",
|
||||||
"path": path,
|
"path": path,
|
||||||
"exists": path.exists(),
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "config",
|
"type": "config",
|
||||||
"path": path,
|
"path": path,
|
||||||
"source": if exists { "file" } else { "built-in-defaults" },
|
"source": if exists { "file" } else { "built-in-defaults" },
|
||||||
"raw": text,
|
"raw": text,
|
||||||
"effective": config_json(&config),
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "config-validation",
|
"type": "config-validation",
|
||||||
"path": path,
|
"path": path,
|
||||||
"valid": true,
|
"valid": true,
|
||||||
"source": if exists { "file" } else { "built-in-defaults" },
|
"source": if exists { "file" } else { "built-in-defaults" },
|
||||||
"effective": config_json(&config),
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "config-updated",
|
"type": "config-updated",
|
||||||
"path": path,
|
"path": path,
|
||||||
"key": key.as_str(),
|
"key": key.as_str(),
|
||||||
"value": value,
|
"value": value,
|
||||||
"restart_required": true,
|
"restart_required": true,
|
||||||
"effective": config_json(&config),
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string(&serde_json::json!({
|
|
||||||
"type": "ephemeral-daemon-starting",
|
"type": "ephemeral-daemon-starting",
|
||||||
"home": paths.home(),
|
"home": paths.home(),
|
||||||
"node_id": node.node_id,
|
"node_id": node.node_id,
|
||||||
"control_command": format!("geth --home {} status", paths.home().display()),
|
"control_command": format!("geth --home {} status", paths.home().display()),
|
||||||
"cleanup": "state is removed after normal daemon shutdown",
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"code": json_error_code(&detail),
|
"code": json_error_code(&detail),
|
||||||
"message": message,
|
"message": message,
|
||||||
"detail": detail,
|
"detail": detail,
|
||||||
"hint": hint,
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "backup-created",
|
"type": "backup-created",
|
||||||
"backup_dir": report.backup_dir,
|
"backup_dir": report.backup_dir,
|
||||||
"files_copied": report.files_copied,
|
"files_copied": report.files_copied,
|
||||||
"bytes_copied": report.bytes_copied,
|
"bytes_copied": report.bytes_copied,
|
||||||
"manifest": report.manifest,
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "backup-restored",
|
"type": "backup-restored",
|
||||||
"backup_dir": report.backup_dir,
|
"backup_dir": report.backup_dir,
|
||||||
"target_home": report.target_home,
|
"target_home": report.target_home,
|
||||||
"files_restored": report.files_restored,
|
"files_restored": report.files_restored,
|
||||||
"bytes_restored": report.bytes_restored,
|
"bytes_restored": report.bytes_restored,
|
||||||
"manifest": report.manifest,
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "doctor",
|
"type": "doctor",
|
||||||
"ok": report.ok,
|
"ok": report.ok,
|
||||||
"checks": report.checks,
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "wait",
|
"type": "wait",
|
||||||
"target": report.target,
|
"target": report.target,
|
||||||
"ready": report.ready,
|
"ready": report.ready,
|
||||||
"elapsed_ms": report.elapsed_ms,
|
"elapsed_ms": report.elapsed_ms,
|
||||||
"attempts": report.attempts,
|
"attempts": report.attempts,
|
||||||
"reason": report.reason,
|
"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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "daemon-install",
|
"type": "daemon-install",
|
||||||
"service": service_report_json(report),
|
"service": service_report_json(report),
|
||||||
"readiness": readiness,
|
"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,11 +5213,9 @@ 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!({
|
||||||
"{}",
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
|
||||||
"type": "service-logs",
|
"type": "service-logs",
|
||||||
"manager": report.manager.to_string(),
|
"manager": report.manager.to_string(),
|
||||||
"service_name": report.service_name,
|
"service_name": report.service_name,
|
||||||
|
|
@ -5199,8 +5224,7 @@ fn print_service_logs_report(report: &ServiceLogsReport, json: bool) -> Result<(
|
||||||
"command": report.command,
|
"command": report.command,
|
||||||
"lines": report.lines,
|
"lines": report.lines,
|
||||||
"note": report.note,
|
"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([
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue