Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file.
- CLI
- Add `--solana-url <SOLANA_RPC_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 <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: <head>` 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

Expand Down
129 changes: 107 additions & 22 deletions client/doublezero/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Environment>() {
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::<Environment>().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::<solana_sdk::pubkey::Pubkey>().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::<solana_sdk::pubkey::Pubkey>().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::<solana_sdk::pubkey::Pubkey>().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::<solana_sdk::pubkey::Pubkey>().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<String>, ...)` 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);

Expand Down Expand Up @@ -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);
}
};
Expand Down
Loading