diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d96a250df..42c4e52e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file. - CLI - Add `--solana-url ` global flag to `doublezero` per RFC-20 §Global flags. Distinct from `--url`, which continues to override the DZ ledger transport; `--solana-url` targets the Solana L1 transport. The flag is parsed and exposed on the binary's `App` struct; per-verb consumption lands when verbs migrate to construct typed Solana L1 clients from `CliContext`. - Add `--log-level ` global flag and initialize the `tracing` subscriber at startup. `LEVEL` is one of `off`, `error`, `warn` (default), `info`, `debug`, `trace`. Diagnostic logs go to stderr so `--json` output on stdout remains parseable. Honors the `RUST_LOG` environment variable when set, overriding the CLI-flag level for per-module filtering. Replaces the previous `println!("using keypair: ...")` stdout line with a `tracing::info!` event; the keypair confirmation now appears only at `--log-level info` or higher and no longer pollutes parseable stdout. (Named `--log-level` rather than the RFC-20 §Global-flags suggested `--verbose` / `-v` because the existing `doublezero connect` / `disconnect` subcommands already own a `--verbose` flag with `bool` type; the global flag deviation will be revisited when the daemon-control module crate is carved out.) + - Build a `CliContext` once at binary startup from `--env`, the per-field global overrides (`--url`, `--ws`, `--solana-url`, `--program-id`, `--geo-program-id`, `--keypair`, `--sock-file`), and the persisted `~/.config/doublezero/cli/config.yml` (overridable via `DOUBLEZERO_CONFIG_FILE`), per RFC-20 (§CliContext). Precedence (highest wins): CLI flag > persisted config > env-derived default. When `--env` is not set and the persisted config has a serviceability program ID, the environment is derived from that program ID via `Environment::from_program_id`; otherwise the binary falls back to `Environment::default()`. The legacy `DZClient` is now constructed from the fully resolved `CliContext` URL, WebSocket, and program-ID values directly, so verbs that migrate to read `CliContext` see the same backend as the legacy bridge. Keypair resolution is intentionally left to `DZClient::new`'s internal `load_keypair` precedence (CLI `--keypair` flag > `DOUBLEZERO_KEYPAIR` env var > stdin > persisted config) so the `DOUBLEZERO_KEYPAIR` env var continues to override the persisted keypair path, as relied on by the e2e contributor-auth negative-authz suite. File reads happen only in the binary; module crates remain forbidden from touching the filesystem (RFC-20 §67). + - Centralize top-level error rendering through `doublezero_cli_core::error::render_eyre`. Replaces three ad-hoc `eprintln!("Error: {e}")` sites in `client/doublezero/src/main.rs` (env-parse failure, env-config resolution failure, top-level command failure) with a single helper that prints `Error: ` followed by the full chain of causes on stderr. ## [v0.24.0](https://github.com/malbeclabs/doublezero/compare/client/v0.23.0...client/v0.24.0) - 2026-05-22 diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 7fcaafd20b..d0c1234434 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -117,29 +117,114 @@ async fn main() -> eyre::Result<()> { tracing::info!(keypair = %keypair.display(), "using keypair"); } - let (url, ws, program_id) = if let Some(env) = app.env { - let config = match env.parse::() { - Ok(env) => match env.config() { - Ok(config) => config, - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - }, - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - }; - ( - Some(config.ledger_public_rpc_url), - Some(config.ledger_public_ws_rpc_url), - Some(config.serviceability_program_id.to_string()), - ) - } else { - (app.url, app.ws, app.program_id) + // Resolve global configuration into a CliContext per RFC-20 (§CliContext). + // The binary populates it once at startup; future verbs read from it. + // + // Precedence (highest wins): CLI flag > persisted `config.yml` > env-derived + // default. File reads happen here in the binary; module crates only read + // resolved values from `CliContext` (RFC-20 §67). + let (persisted_path, persisted) = + doublezero_sdk::read_doublezero_config().unwrap_or_else(|_| { + ( + std::path::PathBuf::new(), + doublezero_sdk::ClientConfig::default(), + ) + }); + let persisted_exists = persisted_path.is_file(); + + let env_explicit = app.env.is_some(); + let env = match app.env.as_deref() { + Some(s) => s.parse::().unwrap_or_else(|e| { + doublezero_cli_core::error::render_eyre(&e); + std::process::exit(1); + }), + None if persisted_exists => persisted + .program_id + .as_deref() + .and_then(|pid| Environment::from_program_id(pid).ok()) + .unwrap_or_default(), + None => Environment::default(), }; + let mut ctx_builder = doublezero_cli_core::CliContextBuilder::new().with_env(env); + + // Layer the persisted config when the file exists. When the user is + // selecting an environment wholesale via `--env`, skip persisted URL and + // program-ID values so we never mix environments; the keypair path is + // orthogonal to env and stays. + if persisted_exists { + if !env_explicit { + ctx_builder = ctx_builder.with_ledger_rpc_url(persisted.json_rpc_url.clone()); + if let Some(ws) = persisted.websocket_url.clone() { + ctx_builder = ctx_builder.with_ledger_ws_rpc_url(ws); + } + if let Some(pid) = persisted + .program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_serviceability_program_id(pid); + } + if let Some(pid) = persisted + .geo_program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_geolocation_program_id(pid); + } + } + ctx_builder = ctx_builder.with_keypair_path(persisted.keypair_path.clone()); + } + + // CLI-flag overrides win. `--env` is mutually exclusive with the per-field + // URL and program-ID flags at the clap layer, so at most one branch of each + // pair fires per invocation. + if let Some(u) = app.url.clone() { + ctx_builder = ctx_builder.with_ledger_rpc_url(u); + } + if let Some(w) = app.ws.clone() { + ctx_builder = ctx_builder.with_ledger_ws_rpc_url(w); + } + if let Some(s) = app.solana_url.clone() { + ctx_builder = ctx_builder.with_solana_l1_rpc_url(s); + } + if let Some(pid) = app + .program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_serviceability_program_id(pid); + } + if let Some(pid) = app + .geo_program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_geolocation_program_id(pid); + } + if let Some(k) = app.keypair.clone() { + ctx_builder = ctx_builder.with_keypair_path(k); + } + if let Some(s) = app.sock_file.clone() { + ctx_builder = ctx_builder.with_daemon_socket_path(s); + } + let ctx = ctx_builder.build().unwrap_or_else(|e| { + doublezero_cli_core::error::render_eyre(&e); + std::process::exit(1); + }); + + // Bridge to the legacy `DZClient::new(Option, ...)` signature. + // CliContext now carries the fully resolved values for URL/WS/program-ID, + // so we forward them directly. The keypair argument is an exception: it + // must reflect only the `--keypair` CLI flag so that `DZClient::new`'s + // internal `load_keypair` precedence chain (CLI flag > `DOUBLEZERO_KEYPAIR` + // env var > stdin > persisted config) is preserved. Passing the layered + // ctx value here would mask the env var, which the e2e contributor-auth + // suite relies on for negative-authz checks. + let url = Some(ctx.ledger_rpc_url.clone()); + let ws = Some(ctx.ledger_ws_rpc_url.clone()); + let program_id = Some(ctx.serviceability_program_id.to_string()); + let dzclient = DZClient::new(url.clone(), ws, program_id, app.keypair.clone())?; let client = CliCommandImpl::new(&dzclient); @@ -447,7 +532,7 @@ async fn main() -> eyre::Result<()> { match res { Ok(_) => {} Err(e) => { - eprintln!("Error: {e}"); + doublezero_cli_core::error::render_eyre(&e); std::process::exit(1); } };