From 7060d1a57dc0d2a77d0c2894d8996116e2f1b316 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:03:01 -0400 Subject: [PATCH 01/11] Group -h/--help and -V/--version under Global options (DX-5694) The auto-generated help/version flags rendered under a lone "Options" heading on every help screen, separate from the real global flags. Disable clap's built-ins and re-declare them as global args so they list with the rest of the globals. Behavior is unchanged: -h prints the summary, --help the long form, and -V works on subcommands via propagate_version. --- src/cli.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 0b15241..bfa5f2c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,6 +26,10 @@ use crate::output::Format; to save a key the first time.", propagate_version = true, disable_help_subcommand = true, + // The auto-generated -h/-V land under a separate "Options" heading; we + // re-declare them below so they group with the other global flags. + disable_help_flag = true, + disable_version_flag = true, after_help = "Examples:\n \ qn auth login\n \ qn endpoint create --chain ethereum --network mainnet\n \ @@ -88,6 +92,14 @@ pub struct Cli { #[arg(long, global = true, hide = true)] pub base_url: Option, + /// Print help (see a summary with '-h'). + #[arg(short = 'h', long, global = true, action = ArgAction::Help)] + pub help: Option, + + /// Print version. + #[arg(short = 'V', long, global = true, action = ArgAction::Version)] + pub version: Option, + #[command(subcommand)] pub command: Command, } From 71f6f628dad8fe5efc2e52f511946a4a733487a1 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:05:19 -0400 Subject: [PATCH 02/11] Gate all endpoint security removes behind confirmation (DX-5694) Only 'security token delete' asked for confirmation; the other six remove verbs (referrer, ip, jwt, domain-mask, request-filter, ip-header) deleted protection rules immediately. All now use the mild confirmation: --yes skips, a TTY prompts, and non-TTY without --yes exits 5 before any request is sent. Prompts name the resource and endpoint. Adds 12 tests (with/without --yes per command, the latter asserting zero requests reach the mock). --- src/commands/endpoint/security.rs | 24 +++ tests/endpoint.rs | 236 ++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/src/commands/endpoint/security.rs b/src/commands/endpoint/security.rs index bcbea0f..eda5f06 100644 --- a/src/commands/endpoint/security.rs +++ b/src/commands/endpoint/security.rs @@ -290,6 +290,10 @@ async fn referrer(cmd: ReferrerCmd, ctx: Ctx) -> Result<(), CliError> { .note(&format!("✓ Whitelisted referrer {referrer:?} on {id}")); } ReferrerCmd::Remove { id, referrer_id } => { + confirm_mild( + &ctx, + &format!("Remove referrer {referrer_id} from endpoint {id}'s whitelist?"), + )?; ctx.sdk.admin.delete_referrer(&id, &referrer_id).await?; ctx.out .note(&format!("✓ Removed referrer {referrer_id} on {id}")); @@ -308,6 +312,10 @@ async fn ip(cmd: IpCmd, ctx: Ctx) -> Result<(), CliError> { ctx.out.note(&format!("✓ Whitelisted IP {ip} on {id}")); } IpCmd::Remove { id, ip_id } => { + confirm_mild( + &ctx, + &format!("Remove IP {ip_id} from endpoint {id}'s whitelist?"), + )?; ctx.sdk.admin.delete_ip(&id, &ip_id).await?; ctx.out.note(&format!("✓ Removed IP {ip_id} on {id}")); } @@ -341,6 +349,10 @@ async fn jwt(cmd: JwtCmd, ctx: Ctx) -> Result<(), CliError> { ctx.out.note(&format!("✓ Added JWT on {}", a.id)); } JwtCmd::Remove { id, jwt_id } => { + confirm_mild( + &ctx, + &format!("Remove JWT {jwt_id} from endpoint {id}?"), + )?; ctx.sdk.admin.delete_jwt(&id, &jwt_id).await?; ctx.out.note(&format!("✓ Removed JWT {jwt_id} on {id}")); } @@ -359,6 +371,10 @@ async fn domain_mask(cmd: DomainMaskCmd, ctx: Ctx) -> Result<(), CliError> { .note(&format!("✓ Added domain mask {domain:?} on {id}")); } DomainMaskCmd::Remove { id, domain_mask_id } => { + confirm_mild( + &ctx, + &format!("Remove domain mask {domain_mask_id} from endpoint {id}?"), + )?; ctx.sdk .admin .delete_domain_mask(&id, &domain_mask_id) @@ -407,6 +423,10 @@ async fn request_filter(cmd: RequestFilterCmd, ctx: Ctx) -> Result<(), CliError> id, request_filter_id, } => { + confirm_mild( + &ctx, + &format!("Remove request filter {request_filter_id} from endpoint {id}?"), + )?; ctx.sdk .admin .delete_request_filter(&id, &request_filter_id) @@ -433,6 +453,10 @@ async fn ip_header(cmd: IpHeaderCmd, ctx: Ctx) -> Result<(), CliError> { .note(&format!("✓ Set IP header {header_name:?} on {id}")); } IpHeaderCmd::Remove { id } => { + confirm_mild( + &ctx, + &format!("Remove the custom IP header configuration on endpoint {id}?"), + )?; ctx.sdk.admin.delete_ip_custom_header(&id).await?; ctx.out.note(&format!("✓ Removed IP header config on {id}")); } diff --git a/tests/endpoint.rs b/tests/endpoint.rs index 2e1e777..08f23b1 100644 --- a/tests/endpoint.rs +++ b/tests/endpoint.rs @@ -457,6 +457,242 @@ async fn endpoint_security_token_delete_without_yes_sends_nothing() { assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); } +#[tokio::test] +async fn endpoint_security_referrer_remove_with_yes() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/referrers/ref-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": true }))) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", "security", "referrer", "remove", "ep-1", "ref-1", "--yes", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_referrer_remove_without_yes_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/referrers/ref-1")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "referrer", "remove", "ep-1", "ref-1"], + ) + .await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_ip_remove_with_yes() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/ips/ip-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": true }))) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "ip", "remove", "ep-1", "ip-1", "--yes"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_ip_remove_without_yes_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/ips/ip-1")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "ip", "remove", "ep-1", "ip-1"], + ) + .await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_jwt_remove_with_yes() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/jwts/jwt-1")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "jwt", "remove", "ep-1", "jwt-1", "--yes"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_jwt_remove_without_yes_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/jwts/jwt-1")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "jwt", "remove", "ep-1", "jwt-1"], + ) + .await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_domain_mask_remove_with_yes() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/domain_masks/dm-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": true }))) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", + "security", + "domain-mask", + "remove", + "ep-1", + "dm-1", + "--yes", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_domain_mask_remove_without_yes_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/domain_masks/dm-1")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", + "security", + "domain-mask", + "remove", + "ep-1", + "dm-1", + ], + ) + .await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_request_filter_remove_with_yes() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/request_filters/rf-1")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", + "security", + "request-filter", + "remove", + "ep-1", + "rf-1", + "--yes", + ], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_request_filter_remove_without_yes_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/security/request_filters/rf-1")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &[ + "endpoint", + "security", + "request-filter", + "remove", + "ep-1", + "rf-1", + ], + ) + .await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_ip_header_remove_with_yes() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/ip_custom_header")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": true }))) + .expect(1) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "ip-header", "remove", "ep-1", "--yes"], + ) + .await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn endpoint_security_ip_header_remove_without_yes_sends_nothing() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v0/endpoints/ep-1/ip_custom_header")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let out = run_qn( + &server.uri(), + &["endpoint", "security", "ip-header", "remove", "ep-1"], + ) + .await; + assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); +} + #[tokio::test] async fn rate_limit_delete_override_with_yes() { let server = MockServer::start().await; From 758cc068fd7881fb51ff888481fd987e6f5a31e5 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:06:20 -0400 Subject: [PATCH 03/11] Soften the 5xx error message (DX-5694) "Quicknode API is having issues" assigned blame for what can be a transient failure. Replace with a neutral message that keeps the HTTP code for debugging and points at support: "something went wrong (HTTP {code}). Please try again; if the problem persists, contact support at https://support.quicknode.com." --- src/errors.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/errors.rs b/src/errors.rs index 2b43a1a..2e49668 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -138,7 +138,8 @@ fn render_api_error(code: u16, body: &str, verbose: bool, argv: &[String]) -> St 404 => "not found.".to_string(), 429 => "rate limited by the Quicknode API. Try again shortly.".to_string(), 500..=599 => format!( - "Quicknode API is having issues (HTTP {code}). Try again or check status.quicknode.com." + "something went wrong (HTTP {code}). Please try again; if the problem persists, \ + contact support at https://support.quicknode.com." ), _ => format!("API returned HTTP {code}."), }; From b16120a687ab98f5f82bff5357a778f3c110fe9d Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:10:22 -0400 Subject: [PATCH 04/11] Use resource-specific value names for positional ids (DX-5694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare 'id' positionals rendered as a generic in usage lines. Every id positional now declares value_name — , , , — so 'qn endpoint show --help' reads 'Usage: qn endpoint show [OPTIONS] '. Multi-word fields (referrer_id, tag_id, …) already rendered descriptively. Documents the convention in CLAUDE.md. --- CLAUDE.md | 1 + src/commands/endpoint/mod.rs | 35 +++++++++++--- src/commands/endpoint/ratelimit.rs | 20 ++++++-- src/commands/endpoint/security.rs | 76 ++++++++++++++++++++++++------ src/commands/endpoint/tag.rs | 2 + src/commands/metrics.rs | 1 + src/commands/stream/mod.rs | 21 +++++++-- src/commands/team.rs | 4 ++ src/commands/webhook/mod.rs | 18 +++++-- 9 files changed, 147 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb31692..36b1527 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ Things to verify for each new endpoint: - **HTTP-noun → CLI-noun-verb**: `admin.get_endpoints` → `qn endpoint list`, `admin.show_endpoint` → `qn endpoint show `, `admin.update_endpoint_status(id, "paused")` → `qn endpoint pause ` (split the verb out, since the user thinks "pause", not "update status to paused"). - **Aliases**: every list-style command gets `#[command(visible_alias = "ls")]`. Plural top-level nouns get one too (`#[command(visible_alias = "endpoints")]`). +- **Positional value names**: a field named `id` renders as an uninformative `` in help. Give every positional an explicit, resource-specific `#[arg(value_name = "ENDPOINT_ID")]` (uppercase, underscored: `STREAM_ID`, `WEBHOOK_ID`, `TEAM_ID`, …). Multi-word fields like `referrer_id` already render fine as ``. - **Hyphenation**: clap kebab-cases enum variants by default. `RateLimit` → `rate-limit`. Test invocations must use the kebab form (`qn endpoint rate-limit method-create`, not `ratelimit`). - **Negative numbers**: any `i64` flag that accepts `-1` (`--end`, etc.) needs `#[arg(long, allow_hyphen_values = true)]` or clap will read it as another flag. - **Multi-value flags**: prefer repeatable `--method foo --method bar` (clap `Vec` with `#[arg(long = "method")]`). Optionally also accept `--methods foo,bar` via a second field with `value_delimiter = ','`. The command body extends one into the other. diff --git a/src/commands/endpoint/mod.rs b/src/commands/endpoint/mod.rs index f3c93ca..1285a11 100644 --- a/src/commands/endpoint/mod.rs +++ b/src/commands/endpoint/mod.rs @@ -43,22 +43,35 @@ pub enum EndpointCmd { /// Create a new endpoint on a chain/network. Create(CreateArgs), /// Show full details for a single endpoint. - Show { id: String }, + Show { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Update an endpoint's label. Update(UpdateArgs), /// Archive an endpoint (irreversible from the CLI). Archive(ArchiveArgs), /// Pause an endpoint (stops accepting requests). - Pause { id: String }, + Pause { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Resume a paused endpoint. - Resume { id: String }, + Resume { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Show the HTTP and WebSocket URLs for an endpoint. - Urls { id: String }, + Urls { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Fetch request logs for an endpoint. Logs(LogsArgs), /// Fetch a single request log's full request/response payloads. LogDetails { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] id: String, /// Request id (UUID from the logs listing). request_id: String, @@ -66,9 +79,15 @@ pub enum EndpointCmd { /// Fetch metric series for an endpoint. Metrics(MetricsArgs), /// Enable multichain on an endpoint. - EnableMultichain { id: String }, + EnableMultichain { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Disable multichain on an endpoint. - DisableMultichain { id: String }, + DisableMultichain { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Manage endpoint tags (per-endpoint add/remove and account-wide list/rename/delete). #[command(subcommand, visible_alias = "tags")] @@ -140,6 +159,7 @@ pub struct CreateArgs { #[derive(Debug, ClapArgs)] pub struct UpdateArgs { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// New label. #[arg(long)] @@ -149,12 +169,14 @@ pub struct UpdateArgs { #[derive(Debug, ClapArgs)] pub struct ArchiveArgs { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] pub id: String, } #[derive(Debug, ClapArgs)] pub struct LogsArgs { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// Start of the window (RFC-3339, relative duration like `1h`, or `now`). #[arg(long)] @@ -176,6 +198,7 @@ pub struct LogsArgs { #[derive(Debug, ClapArgs)] pub struct MetricsArgs { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// Metric name (e.g. `method_calls_over_time`, `response_status_breakdown`). #[arg(long)] diff --git a/src/commands/endpoint/ratelimit.rs b/src/commands/endpoint/ratelimit.rs index 4904618..6cb52d0 100644 --- a/src/commands/endpoint/ratelimit.rs +++ b/src/commands/endpoint/ratelimit.rs @@ -16,20 +16,31 @@ use crate::retry::retrying; #[derive(Debug, Subcommand)] pub enum RateLimitCmd { /// Show the endpoint's current per-bucket rate limits. - Get { id: String }, + Get { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Set endpoint-level rate limits (any of rps/rpm/rpd; omitted are untouched). Set(SetArgs), /// Delete a user-set rate-limit override (plan defaults can't be deleted). - DeleteOverride { id: String, override_id: String }, + DeleteOverride { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + override_id: String, + }, /// List method-level rate limiters on the endpoint. - MethodList { id: String }, + MethodList { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Create a new method-level rate limiter. MethodCreate(MethodCreateArgs), /// Update an existing method-level rate limiter. MethodUpdate(MethodUpdateArgs), /// Delete a method-level rate limiter. MethodDelete { + #[arg(value_name = "ENDPOINT_ID")] id: String, method_rate_limit_id: String, }, @@ -37,6 +48,7 @@ pub enum RateLimitCmd { #[derive(Debug, ClapArgs)] pub struct SetArgs { + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// Requests-per-second cap. #[arg(long)] @@ -51,6 +63,7 @@ pub struct SetArgs { #[derive(Debug, ClapArgs)] pub struct MethodCreateArgs { + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// Interval (second/minute/hour/day). #[arg(long, value_parser = ["second", "minute", "hour", "day"])] @@ -68,6 +81,7 @@ pub struct MethodCreateArgs { #[derive(Debug, ClapArgs)] pub struct MethodUpdateArgs { + #[arg(value_name = "ENDPOINT_ID")] pub id: String, pub method_rate_limit_id: String, #[arg(long = "method")] diff --git a/src/commands/endpoint/security.rs b/src/commands/endpoint/security.rs index eda5f06..44498c6 100644 --- a/src/commands/endpoint/security.rs +++ b/src/commands/endpoint/security.rs @@ -22,11 +22,13 @@ pub enum SecurityCmd { /// Show the endpoint's full security configuration (tokens, referrers, IPs, ...). Show { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] id: String, }, /// List the security feature toggles and their current state. Options { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] id: String, }, /// Enable/disable individual security feature toggles. @@ -72,6 +74,7 @@ impl Toggle { #[derive(Debug, ClapArgs)] pub struct SetOptionsArgs { + #[arg(value_name = "ENDPOINT_ID")] pub id: String, #[arg(long, value_enum)] pub tokens: Option, @@ -96,25 +99,48 @@ pub struct SetOptionsArgs { #[derive(Debug, Subcommand)] pub enum TokenCmd { /// Generate a new auth token. - Create { id: String }, + Create { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, /// Delete an auth token. - Delete { id: String, token_id: String }, + Delete { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + token_id: String, + }, } #[derive(Debug, Subcommand)] pub enum ReferrerCmd { /// Whitelist a referrer URL or domain. - Add { id: String, referrer: String }, + Add { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + referrer: String, + }, /// Remove a referrer. - Remove { id: String, referrer_id: String }, + Remove { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + referrer_id: String, + }, } #[derive(Debug, Subcommand)] pub enum IpCmd { /// Whitelist an IP address. - Add { id: String, ip: String }, + Add { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + ip: String, + }, /// Remove an IP. - Remove { id: String, ip_id: String }, + Remove { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + ip_id: String, + }, } #[derive(Debug, Subcommand)] @@ -122,11 +148,16 @@ pub enum JwtCmd { /// Configure JWT validation. Supply the PEM public key inline or via --public-key-file. Add(JwtAddArgs), /// Remove a JWT configuration. - Remove { id: String, jwt_id: String }, + Remove { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + jwt_id: String, + }, } #[derive(Debug, ClapArgs)] pub struct JwtAddArgs { + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// PEM public key string. #[arg(long)] @@ -145,9 +176,17 @@ pub struct JwtAddArgs { #[derive(Debug, Subcommand)] pub enum DomainMaskCmd { /// Add a custom domain mask. - Add { id: String, domain: String }, + Add { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + domain: String, + }, /// Remove a domain mask. - Remove { id: String, domain_mask_id: String }, + Remove { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + domain_mask_id: String, + }, } #[derive(Debug, Subcommand)] @@ -158,6 +197,7 @@ pub enum RequestFilterCmd { Update(RequestFilterUpdateArgs), /// Remove a request filter. Remove { + #[arg(value_name = "ENDPOINT_ID")] id: String, request_filter_id: String, }, @@ -165,6 +205,7 @@ pub enum RequestFilterCmd { #[derive(Debug, ClapArgs)] pub struct RequestFilterCreateArgs { + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// RPC method to whitelist; pass --method multiple times or use --methods comma-list. #[arg(long = "method")] @@ -176,6 +217,7 @@ pub struct RequestFilterCreateArgs { #[derive(Debug, ClapArgs)] pub struct RequestFilterUpdateArgs { + #[arg(value_name = "ENDPOINT_ID")] pub id: String, pub request_filter_id: String, #[arg(long = "method")] @@ -187,9 +229,16 @@ pub struct RequestFilterUpdateArgs { #[derive(Debug, Subcommand)] pub enum IpHeaderCmd { /// Set the custom header used to identify the client IP. - Set { id: String, header_name: String }, + Set { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + header_name: String, + }, /// Remove the custom IP header configuration. - Remove { id: String }, + Remove { + #[arg(value_name = "ENDPOINT_ID")] + id: String, + }, } pub async fn run(cmd: SecurityCmd, ctx: Ctx) -> Result<(), CliError> { @@ -349,10 +398,7 @@ async fn jwt(cmd: JwtCmd, ctx: Ctx) -> Result<(), CliError> { ctx.out.note(&format!("✓ Added JWT on {}", a.id)); } JwtCmd::Remove { id, jwt_id } => { - confirm_mild( - &ctx, - &format!("Remove JWT {jwt_id} from endpoint {id}?"), - )?; + confirm_mild(&ctx, &format!("Remove JWT {jwt_id} from endpoint {id}?"))?; ctx.sdk.admin.delete_jwt(&id, &jwt_id).await?; ctx.out.note(&format!("✓ Removed JWT {jwt_id} on {id}")); } diff --git a/src/commands/endpoint/tag.rs b/src/commands/endpoint/tag.rs index 67b0773..cb1c593 100644 --- a/src/commands/endpoint/tag.rs +++ b/src/commands/endpoint/tag.rs @@ -34,6 +34,7 @@ pub enum TagCmd { /// Tag an endpoint. Creates the tag on the account if missing. Add { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] id: String, /// Tag label. label: String, @@ -41,6 +42,7 @@ pub enum TagCmd { /// Remove a tag from an endpoint. `tag_id` is the numeric tag id from `qn endpoint tag list`. Remove { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] id: String, /// Tag id (string). tag_id: String, diff --git a/src/commands/metrics.rs b/src/commands/metrics.rs index f6911bf..7076430 100644 --- a/src/commands/metrics.rs +++ b/src/commands/metrics.rs @@ -39,6 +39,7 @@ pub struct AccountArgs { #[derive(Debug, ClapArgs)] pub struct EndpointArgs { /// Endpoint id. + #[arg(value_name = "ENDPOINT_ID")] pub id: String, /// Period. #[arg(long, value_parser = ["hour", "day", "week", "month"])] diff --git a/src/commands/stream/mod.rs b/src/commands/stream/mod.rs index 53489c4..f3b8833 100644 --- a/src/commands/stream/mod.rs +++ b/src/commands/stream/mod.rs @@ -39,15 +39,27 @@ pub enum StreamCmd { /// Create a stream (webhook destination). For non-webhook destinations, use --stream-config-file. Create(Box), /// Show a stream's full configuration and current state. - Show { id: String }, + Show { + #[arg(value_name = "STREAM_ID")] + id: String, + }, /// Update editable fields on a stream. Update(UpdateArgs), /// Delete a stream. - Delete { id: String }, + Delete { + #[arg(value_name = "STREAM_ID")] + id: String, + }, /// Activate (resume) a stream. - Activate { id: String }, + Activate { + #[arg(value_name = "STREAM_ID")] + id: String, + }, /// Pause a stream. - Pause { id: String }, + Pause { + #[arg(value_name = "STREAM_ID")] + id: String, + }, /// Run a filter against a block without creating a stream. TestFilter(TestFilterArgs), /// Count of currently enabled streams. @@ -150,6 +162,7 @@ pub struct CreateArgs { #[derive(Debug, ClapArgs)] pub struct UpdateArgs { + #[arg(value_name = "STREAM_ID")] pub id: String, #[arg(long)] pub name: Option, diff --git a/src/commands/team.rs b/src/commands/team.rs index 5196268..f1ac68f 100644 --- a/src/commands/team.rs +++ b/src/commands/team.rs @@ -33,16 +33,19 @@ pub enum TeamCmd { /// Show team detail. Show { /// Team id (numeric). + #[arg(value_name = "TEAM_ID")] id: i64, }, /// Delete a team. Delete { /// Team id (numeric). + #[arg(value_name = "TEAM_ID")] id: i64, }, /// List endpoints associated with a team. Endpoints { /// Team id (numeric). + #[arg(value_name = "TEAM_ID")] id: i64, }, /// Replace the set of endpoints associated with a team. @@ -55,6 +58,7 @@ pub enum TeamCmd { #[derive(Debug, ClapArgs)] pub struct SetEndpointsArgs { /// Team id (numeric). + #[arg(value_name = "TEAM_ID")] pub id: i64, /// Endpoint ids to associate (pass each as an additional positional arg). pub endpoint_ids: Vec, diff --git a/src/commands/webhook/mod.rs b/src/commands/webhook/mod.rs index 6f0edd2..5283596 100644 --- a/src/commands/webhook/mod.rs +++ b/src/commands/webhook/mod.rs @@ -28,7 +28,10 @@ pub enum WebhookCmd { #[command(visible_alias = "ls")] List(ListArgs), /// Show a single webhook. - Show { id: String }, + Show { + #[arg(value_name = "WEBHOOK_ID")] + id: String, + }, /// Create a webhook from a filter template. Create(Box), /// Update name/email/destination on a webhook (without changing the template). @@ -36,11 +39,17 @@ pub enum WebhookCmd { /// Update the template arguments on a webhook (and optionally other fields). UpdateTemplate(Box), /// Delete a webhook. - Delete { id: String }, + Delete { + #[arg(value_name = "WEBHOOK_ID")] + id: String, + }, /// Activate a webhook (resume delivery). Activate(ActivateArgs), /// Pause a webhook. - Pause { id: String }, + Pause { + #[arg(value_name = "WEBHOOK_ID")] + id: String, + }, /// Count of currently enabled webhooks. EnabledCount, } @@ -112,6 +121,7 @@ pub struct CreateArgs { #[derive(Debug, ClapArgs)] pub struct UpdateArgs { + #[arg(value_name = "WEBHOOK_ID")] pub id: String, #[arg(long)] pub name: Option, @@ -128,6 +138,7 @@ pub struct UpdateArgs { #[derive(Debug, ClapArgs)] pub struct UpdateTemplateArgs { + #[arg(value_name = "WEBHOOK_ID")] pub id: String, /// New filter template (same flags as `create`). #[arg(long, value_enum)] @@ -160,6 +171,7 @@ pub struct UpdateTemplateArgs { #[derive(Debug, ClapArgs)] pub struct ActivateArgs { + #[arg(value_name = "WEBHOOK_ID")] pub id: String, /// Where to resume from. #[arg(long, value_enum, default_value = "latest")] From 092ec0d44f3d51fb9d139bb829c982988fcc3118 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:10:53 -0400 Subject: [PATCH 05/11] Add onboarding preamble to interactive 'qn auth login' (DX-5694) The command jumped straight to a hidden key prompt with no context. Interactive logins now print a short welcome on stderr explaining that an API key is needed, where it will be stored, and where to get one (dashboard.quicknode.com/api-keys, quicknode.com/signup). Suppressed by --quiet; not shown when --api-key is passed. --- src/commands/auth.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 3bcf67b..3826a83 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -70,6 +70,16 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> { "no TTY available; pass --api-key to log in non-interactively".to_string(), )); } + if !global.quiet { + let _ = writeln!( + std::io::stderr(), + "Welcome! The qn CLI uses a Quicknode API key to manage your account.\n\ + Your key is stored locally in {}.\n\n \ + Get an API key: https://dashboard.quicknode.com/api-keys\n \ + Need an account? https://www.quicknode.com/signup\n", + path.display() + ); + } config::prompt_for_api_key()? } }; From 0e83ea3c5eae893c4dc7ce3e46dc1ed5a50b4426 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:12:09 -0400 Subject: [PATCH 06/11] Accept true/false for --fix-block-reorgs (DX-5694) The flag took a raw 0/1 integer. It now parses as a bool via clap's BoolishValueParser, so the documented form is true/false while 0/1 (and yes/no) keep working. The value still maps to the integer the streams API expects. --- src/commands/stream/actions.rs | 3 ++- src/commands/stream/mod.rs | 6 ++--- tests/streams.rs | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/commands/stream/actions.rs b/src/commands/stream/actions.rs index 420f318..d4da667 100644 --- a/src/commands/stream/actions.rs +++ b/src/commands/stream/actions.rs @@ -92,7 +92,8 @@ fn build_create_params(a: CreateArgs) -> Result { status: a.status.map(Into::into), notification_email: a.notification_email, charge_min_cap: None, - fix_block_reorgs: a.fix_block_reorgs, + // The API models this as 0/1. + fix_block_reorgs: a.fix_block_reorgs.map(i32::from), elastic_batch_enabled: a.elastic_batch_enabled.unwrap_or(false), extra_destinations: None, }) diff --git a/src/commands/stream/mod.rs b/src/commands/stream/mod.rs index f3b8833..b740613 100644 --- a/src/commands/stream/mod.rs +++ b/src/commands/stream/mod.rs @@ -131,9 +131,9 @@ pub struct CreateArgs { /// dataset_batch_size (defaults to 1). #[arg(long)] pub batch_size: Option, - /// fix_block_reorgs (0/1). - #[arg(long)] - pub fix_block_reorgs: Option, + /// Fix block reorgs (true/false). + #[arg(long, value_parser = clap::builder::BoolishValueParser::new())] + pub fix_block_reorgs: Option, /// elastic_batch_enabled. #[arg(long)] pub elastic_batch_enabled: Option, diff --git a/tests/streams.rs b/tests/streams.rs index ddbdc48..8a12f41 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -81,6 +81,46 @@ async fn create_stream_webhook_destination() { assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } +#[tokio::test] +async fn create_stream_fix_block_reorgs_accepts_bool_and_sends_int() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/streams/rest/v1/streams")) + .and(body_partial_json(json!({ "fix_block_reorgs": 1 }))) + .respond_with(ResponseTemplate::new(200).set_body_json(stream_payload("s-new"))) + .expect(2) + .mount(&server) + .await; + // `true` is the documented form; bare `1` keeps working via the boolish parser. + for value in ["true", "1"] { + let out = run_qn( + &server.uri(), + &[ + "stream", + "create", + "--name", + "s1", + "--network", + "ethereum-mainnet", + "--dataset", + "block", + "--start", + "100", + "--end", + "-1", + "--region", + "usa-east", + "--webhook", + "https://hook.example/x", + "--fix-block-reorgs", + value, + ], + ) + .await; + assert_eq!(out.exit_code, 0, "value={value} stderr={}", out.stderr); + } +} + #[tokio::test] async fn create_stream_webhook_destination_with_gzip() { let server = MockServer::start().await; From 49c03496e663935a2178c4ec2aa796864f094de9 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:13:15 -0400 Subject: [PATCH 07/11] Reword usage --to help text (DX-5694) "Omit for now." read as a temporary instruction rather than "defaults to the current time". Now: "End time. Defaults to now." --- src/commands/usage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/usage.rs b/src/commands/usage.rs index 3a6a020..13a4632 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -36,7 +36,7 @@ pub struct Range { /// Start time (RFC-3339, relative like `7d`, or `now`). Omit for account-to-date. #[arg(long)] pub from: Option, - /// End time. Omit for now. + /// End time. Defaults to now. #[arg(long)] pub to: Option, } From eaa097d10f1b25fd99b91e782619993cfca46b07 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:13:15 -0400 Subject: [PATCH 08/11] Make 'list' the primary verb for kv listings (DX-5694) 'qn kv set ls' and 'qn kv list ls' were the only listings with 'ls' as the primary name. Renamed to 'list' with 'ls' as a visible alias, matching the convention everywhere else in the CLI. Both spellings keep working; existing tests invoke 'ls' and pass via the alias. --- src/commands/kv/actions.rs | 4 ++-- src/commands/kv/mod.rs | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/kv/actions.rs b/src/commands/kv/actions.rs index 3534ebe..087a5f6 100644 --- a/src/commands/kv/actions.rs +++ b/src/commands/kv/actions.rs @@ -39,7 +39,7 @@ pub(super) async fn set(cmd: SetCmd, ctx: Ctx) -> Result<(), CliError> { println!("{}", resp.value); } } - SetCmd::Ls(a) => { + SetCmd::List(a) => { let params = GetSetsParams { limit: a.limit, cursor: a.cursor, @@ -86,7 +86,7 @@ pub(super) async fn set(cmd: SetCmd, ctx: Ctx) -> Result<(), CliError> { pub(super) async fn list(cmd: ListCmd, ctx: Ctx) -> Result<(), CliError> { match cmd { - ListCmd::Ls(a) => { + ListCmd::List(a) => { let params = GetListsParams { limit: a.limit, cursor: a.cursor, diff --git a/src/commands/kv/mod.rs b/src/commands/kv/mod.rs index c8c8459..f5c44fe 100644 --- a/src/commands/kv/mod.rs +++ b/src/commands/kv/mod.rs @@ -38,7 +38,8 @@ pub enum SetCmd { /// Get the value stored under a key. Get { key: String }, /// List all key/value entries. - Ls(SetsLsArgs), + #[command(visible_alias = "ls")] + List(SetsLsArgs), /// Delete a single set. Delete { key: String }, /// Add and/or delete multiple sets in one call. @@ -66,7 +67,8 @@ pub struct BulkArgs { #[derive(Debug, Subcommand)] pub enum ListCmd { /// List all list keys. - Ls(ListsLsArgs), + #[command(visible_alias = "ls")] + List(ListsLsArgs), /// Get items in a specific list (paginated). Get(ListGetArgs), /// Create a new list seeded with items. From bd272ba583cf796ba3364641616c58d7c2889533 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:14:54 -0400 Subject: [PATCH 09/11] Apply rustfmt to new security confirmation tests --- tests/endpoint.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/endpoint.rs b/tests/endpoint.rs index 08f23b1..4c50614 100644 --- a/tests/endpoint.rs +++ b/tests/endpoint.rs @@ -487,7 +487,9 @@ async fn endpoint_security_referrer_remove_without_yes_sends_nothing() { .await; let out = run_qn( &server.uri(), - &["endpoint", "security", "referrer", "remove", "ep-1", "ref-1"], + &[ + "endpoint", "security", "referrer", "remove", "ep-1", "ref-1", + ], ) .await; assert_eq!(out.exit_code, 5, "stderr={}", out.stderr); @@ -504,7 +506,9 @@ async fn endpoint_security_ip_remove_with_yes() { .await; let out = run_qn( &server.uri(), - &["endpoint", "security", "ip", "remove", "ep-1", "ip-1", "--yes"], + &[ + "endpoint", "security", "ip", "remove", "ep-1", "ip-1", "--yes", + ], ) .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); @@ -538,7 +542,9 @@ async fn endpoint_security_jwt_remove_with_yes() { .await; let out = run_qn( &server.uri(), - &["endpoint", "security", "jwt", "remove", "ep-1", "jwt-1", "--yes"], + &[ + "endpoint", "security", "jwt", "remove", "ep-1", "jwt-1", "--yes", + ], ) .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); @@ -670,7 +676,14 @@ async fn endpoint_security_ip_header_remove_with_yes() { .await; let out = run_qn( &server.uri(), - &["endpoint", "security", "ip-header", "remove", "ep-1", "--yes"], + &[ + "endpoint", + "security", + "ip-header", + "remove", + "ep-1", + "--yes", + ], ) .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); From 589bd1588c55a0c7f1ecb2a831523a36b39e4fb5 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:16:01 -0400 Subject: [PATCH 10/11] Trim README auth and install docs (DX-5694) Scope the Homebrew install section to macOS, and shorten the authentication and environment sections: state where the key comes from and how to log in, without the extended design rationale. --- README.md | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f51ecb2..eac70a4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ error: null Pick the recommended path for your platform. Other channels are listed under [Alternatives](#alternatives). -### Homebrew (macOS, Linux) +### Homebrew (macOS) ```sh brew install quicknode/tap/qn @@ -110,15 +110,8 @@ You will need a Quicknode API key to get started. Once you have that, you can ru env var is set. The same layout applies on Windows: `%USERPROFILE%\.config\qn\config.toml`. Managed by `qn auth login`. -There is deliberately **no environment-variable key source**: a key left -exported in a shell is invisible state that outlives the session it was set -for, and makes it far too easy to run a destructive command against the wrong -account. For CI, write a config file and point `--config-file` at it (or pass -`--api-key` from your secret store). - If no source matches, `qn` exits with code 4 and tells you to run -`qn auth login`. Regular commands never prompt — only `qn auth login` does. -This keeps scripts and CI deterministic. +`qn auth login`. ```sh qn auth login # prompts for the key, writes it to ~/.config/qn/config.toml @@ -252,13 +245,10 @@ qn completions powershell > qn.ps1 ## Configuration via environment -`qn` reads no API credentials from the environment (see -[Authentication](#authentication) for why). The conventional variables are -honored: `NO_COLOR` and `TERM=dumb` disable color, and -`XDG_CONFIG_HOME`/`HOME` (`USERPROFILE` on Windows) locate the default -config file. The CLI hands the -key to the SDK explicitly; it does not read the SDK's `QN_SDK__*` environment -namespace. +The conventional variables are honored: `NO_COLOR` and `TERM=dumb` disable color, +and `XDG_CONFIG_HOME`/`HOME` (`USERPROFILE` on Windows) locate the default +config file. The CLI hands the key to the Quicknode SDK explicitly; it does +not read the SDK's `QN_SDK__*` environment namespace. The hidden `--base-url ` flag overrides the API host for all four sub-clients at once (used for integration tests and on-prem mirrors). From fdd800ecaf9baef315ddac52f4e053175d8c49c1 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 12 Jun 2026 11:17:10 -0400 Subject: [PATCH 11/11] Strip trailing whitespace in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eac70a4..8b2b898 100644 --- a/README.md +++ b/README.md @@ -245,9 +245,9 @@ qn completions powershell > qn.ps1 ## Configuration via environment -The conventional variables are honored: `NO_COLOR` and `TERM=dumb` disable color, +The conventional variables are honored: `NO_COLOR` and `TERM=dumb` disable color, and `XDG_CONFIG_HOME`/`HOME` (`USERPROFILE` on Windows) locate the default -config file. The CLI hands the key to the Quicknode SDK explicitly; it does +config file. The CLI hands the key to the Quicknode SDK explicitly; it does not read the SDK's `QN_SDK__*` environment namespace. The hidden `--base-url ` flag overrides the API host for all four