Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ Things to verify for each new endpoint:
- **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<String>` 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.
- **Large `Args` structs**: clippy's `large_enum_variant` will reject an enum variant whose payload is > ~200 bytes. Box it: `Create(Box<CreateArgs>)` (we hit this on `StreamCmd::Create` and `WebhookCmd::Create`).
- **Destructive operations**: route through `confirm::decide_without_prompt(Severity::Mild|Severe, ConfirmCfg)`. Mild = single `--yes` skips, TTY prompts otherwise. Severe = `--yes --yes` skips OR user types a literal word (`delete-all`). Non-TTY without enough `--yes` returns `CliError::NeedsConfirmation` (exit 5).
- **Destructive operations**: the policy is non-negotiable —
- Anything that deletes, revokes, or loosens protection (delete/archive verbs, token revocation, removing a rate-limit override, pausing many endpoints) gets **at least `Severity::Mild`**: single `--yes` skips, TTY prompts otherwise, non-TTY without `--yes` returns `CliError::NeedsConfirmation` (exit 5). Use the `confirm::confirm_mild(&ctx, msg)` helper.
- Prompts must name the resource and the blast radius: "Pause 47 endpoint(s)? They will stop serving requests", not "Are you sure?". Include counts for bulk operations.
- **Account-wide wipe verbs (delete-all style) are not offered by the CLI at all.** Don't add one; point users at the API instead.
- Commands that restore service (`resume`, `activate`) are not gated.
- `Severity::Severe` (typed-word + `--yes --yes`) exists in `confirm.rs` for future wide-blast-radius operations; nothing uses it today.
- Every gated command needs both tests: no `--yes` non-TTY ⇒ exit 5 **and zero requests reach the mock** (`.expect(0)`); `--yes` ⇒ exit 0.
- **Args that span multiple subcommands**: for things like rate-limits where both account-level and method-level CRUD exists, use a single `RateLimitCmd` enum with `MethodList/MethodCreate/...` variants — don't fight clap by trying to nest a third level.

### 3. Module layout
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ path = "src/lib.rs"
quicknode-sdk = "0.1"
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
clap_complete = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
fastrand = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yml = "0.0.12"
Expand All @@ -41,6 +42,7 @@ url = "2"

[dev-dependencies]
reqwest = { version = "0.12", default-features = false }
tokio = { version = "1", features = ["test-util"] }
wiremock = "0.6"
assert_cmd = "2"
predicates = "3"
Expand Down
60 changes: 45 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,19 @@ You will need a Quicknode API key to get started. Once you have that, you can ru
`qn` resolves your API key from the first source that matches:

1. `--api-key <KEY>` flag
2. `QN_CLI__API_KEY` environment variable
3. `~/.config/qn/config.toml` — or `$XDG_CONFIG_HOME/qn/config.toml` if that env var is set. Managed by `qn auth login`.
2. The config file: the `--config-file <PATH>` flag if given, otherwise
`~/.config/qn/config.toml` — or `$XDG_CONFIG_HOME/qn/config.toml` if that
env var is set. Managed by `qn auth login`.

If none match, `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.
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.

```sh
qn auth login # prompts for the key, writes it to ~/.config/qn/config.toml
Expand Down Expand Up @@ -196,28 +203,51 @@ qn completions powershell > qn.ps1

## Configuration via environment

| Variable | Description |
|---|---|
| `QN_CLI__API_KEY` | Your Quicknode API key |

`qn` deliberately uses its own `QN_CLI__` namespace so the CLI's env vars don't
collide with — or silently leak into — direct use of the underlying SDK. The
CLI hands the key to the SDK explicitly; it does not read the SDK's
`QN_SDK__*` environment namespace.
`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` 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 hidden `--base-url <URL>` flag overrides the API host for all four
sub-clients at once (used for integration tests and on-prem mirrors).

## Confirmations

Destructive commands (`delete`, `archive`, `bulk pause`, token revocation,
removing a rate-limit override, …) prompt before acting, and the prompt states
what will happen ("Pause 3 endpoint(s)? They will stop serving requests").
Pass `--yes`/`-y` to skip the prompt. In scripts and CI (no TTY), a gated
command without `--yes` exits with code 5 **before** any request is sent.

The CLI deliberately has no account-wide wipe commands (no `delete-all`);
operations with that blast radius belong behind the API, not a one-liner.

## Retries

Read-only commands (`list`, `show`, `logs`, `metrics`, `usage`, …) retry
transient failures — HTTP 429, 500, 502, 503, 504, timeouts, and connection
errors — with exponential backoff and full jitter. The default is 3 retries;
tune it with the global `--retries <N>` flag (`--retries 0` disables).
`stream test-filter` retries too: it sends a POST, but only evaluates a
filter against historical data and changes nothing.

Commands that modify resources (`create`, `update`, `delete`, `pause`, …)
**never** retry automatically: a retried create could provision twice. If a
mutation fails with a transient error, check whether it took effect before
re-running it.

## Exit codes

| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | CLI error (bad argument, IO, decode) |
| 1 | CLI error (usage/bad argument, IO, decode) |
| 2 | API error (server returned 4xx/5xx) |
| 3 | Network failure (timeout, connect, transport) |
| 4 | Missing or invalid API key / config |
| 5 | Operation needs confirmation (pass `--yes` or `--yes --yes`) |
| 5 | Operation needs confirmation (pass `--yes`) |
| 130 | Interrupted (SIGINT) |

## License
Expand Down
34 changes: 28 additions & 6 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,30 @@ use crate::output::Format;
about = "Command-line interface for the Quicknode API.",
long_about = "qn lets you manage Quicknode endpoints, streams, webhooks, and the KV store from the terminal.\n\n\
Use `qn <noun> --help` (e.g. `qn endpoint --help`) for command details.\n\n\
Authentication is resolved in this order: --api-key flag, QN_CLI__API_KEY env var,\n\
~/.config/qn/config.toml. Run `qn auth login` to save a key the first time.",
Authentication is resolved in this order: --api-key flag, then the config file\n\
(--config-file path if given, else ~/.config/qn/config.toml). Run `qn auth login`\n\
to save a key the first time.",
propagate_version = true,
disable_help_subcommand = true
disable_help_subcommand = true,
after_help = "Examples:\n \
qn auth login\n \
qn endpoint create --chain ethereum --network mainnet\n \
qn endpoint list -o json\n \
qn endpoint logs ep-1234 --from 1h\n \
qn chain list",
// Group the global flags under their own heading in every subcommand's
// --help, so command-specific flags surface first under "Options".
next_help_heading = "Global options"
)]
pub struct Cli {
/// API key. Overrides QN_CLI__API_KEY and the config file.
#[arg(long, global = true, env = "QN_CLI__API_KEY", hide_env_values = true)]
/// API key. Overrides the config file.
#[arg(long, global = true)]
pub api_key: Option<String>,

/// Path to an alternate config file (default: ~/.config/qn/config.toml).
#[arg(long, global = true, value_name = "PATH")]
pub config_file: Option<std::path::PathBuf>,

/// Output format. `table` is the default human view; the others are
/// pipeline-friendly serialized forms. If unset, falls back to the
/// `[output] format = "…"` value in ~/.config/qn/config.toml, then `table`.
Expand Down Expand Up @@ -59,7 +73,13 @@ pub struct Cli {
#[arg(long, global = true)]
pub no_input: bool,

/// Skip confirmation prompts. Pass twice for destructive bulk operations like `stream delete-all`.
/// Max automatic retries for read-only commands on transient failures
/// (HTTP 429/500/502/503/504, timeouts). Uses exponential backoff with
/// jitter. 0 disables retries. Commands that modify resources never retry.
#[arg(long, global = true, default_value_t = 3, value_name = "N")]
pub retries: u32,

/// Skip confirmation prompts on destructive operations.
#[arg(short = 'y', long = "yes", global = true, action = ArgAction::Count)]
pub yes: u8,

Expand Down Expand Up @@ -122,6 +142,7 @@ impl Cli {
pub fn global_args(&self) -> GlobalArgs {
GlobalArgs {
api_key: self.api_key.clone(),
config_file: self.config_file.clone(),
format: self.format,
wide: self.wide,
// format resolved-from-config in Ctx::from_global; auth.rs falls
Expand All @@ -131,6 +152,7 @@ impl Cli {
verbose: self.verbose,
no_input: self.no_input,
yes_count: self.yes,
retries: self.retries,
base_url: self.base_url.clone(),
}
}
Expand Down
26 changes: 13 additions & 13 deletions src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ use crate::context::GlobalArgs;
use crate::errors::CliError;

#[derive(Debug, ClapArgs)]
#[command(after_help = "Examples:\n \
qn auth login # prompts for the key (hidden input)\n \
qn auth login --api-key <KEY> # non-interactive (e.g. CI)\n \
qn auth whoami # verify the key against the API\n \
qn --config-file ./ci.toml auth status")]
pub struct Args {
#[command(subcommand)]
pub cmd: AuthCmd,
Expand Down Expand Up @@ -50,7 +55,7 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> {
}

async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> {
let path = config::config_path().ok_or_else(|| {
let path = global.resolve_config_path().ok_or_else(|| {
CliError::Arg("no config directory available on this platform".to_string())
})?;

Expand All @@ -72,7 +77,7 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> {

// Quick validation against the API so we don't silently save a bogus key.
let sdk = QuicknodeSdk::new(&SdkFullConfig::from_api_key(key.clone()))?;
sdk.admin.list_chains().await?;
crate::retry::retrying(global.retries, || sdk.admin.list_chains()).await?;

config::save_api_key(&path, &key)?;
if !global.quiet {
Expand All @@ -82,7 +87,7 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> {
}

fn logout(global: GlobalArgs) -> Result<(), CliError> {
let path = config::config_path().ok_or_else(|| {
let path = global.resolve_config_path().ok_or_else(|| {
CliError::Arg("no config directory available on this platform".to_string())
})?;
config::delete_config(&path)?;
Expand All @@ -102,7 +107,7 @@ async fn whoami(global: GlobalArgs) -> Result<(), CliError> {
let (key, source) = resolve_non_interactive(&global)?;
let redacted = redact(&key);
let sdk = QuicknodeSdk::new(&SdkFullConfig::from_api_key(key))?;
let result = sdk.admin.list_chains().await;
let result = crate::retry::retrying(global.retries, || sdk.admin.list_chains()).await;
let ok = result.is_ok();
print_status(&global, source, &redacted, Some(ok));
result.map(|_| ()).map_err(Into::into)
Expand All @@ -111,15 +116,10 @@ async fn whoami(global: GlobalArgs) -> Result<(), CliError> {
/// Resolves the key just like the rest of the CLI — no prompting. Returns the
/// raw key (callers must redact before printing) along with its source.
fn resolve_non_interactive(global: &GlobalArgs) -> Result<(String, KeySource), CliError> {
let env_key = config::read_env_api_key();
let path = config::config_path();
config::resolve_api_key(
global.api_key.as_deref(),
env_key.as_deref(),
path.as_deref(),
false,
|| unreachable!("prompt disabled for auth status/whoami"),
)
let path = global.resolve_config_path();
config::resolve_api_key(global.api_key.as_deref(), path.as_deref(), false, || {
unreachable!("prompt disabled for auth status/whoami")
})
}

/// Show the last 4 chars only. Char-based slicing — never panics on multi-byte input.
Expand Down
5 changes: 3 additions & 2 deletions src/commands/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde::Serialize;
use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render};
use crate::retry::retrying;

#[derive(Debug, ClapArgs)]
pub struct Args {
Expand All @@ -30,12 +31,12 @@ pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
}

async fn invoices(ctx: Ctx) -> Result<(), CliError> {
let resp = ctx.sdk.admin.list_invoices().await?;
let resp = retrying(ctx.global.retries, || ctx.sdk.admin.list_invoices()).await?;
crate::output::emit(&ctx.out, &InvoicesView(resp))
}

async fn payments(ctx: Ctx) -> Result<(), CliError> {
let resp = ctx.sdk.admin.list_payments().await?;
let resp = retrying(ctx.global.retries, || ctx.sdk.admin.list_payments()).await?;
crate::output::emit(&ctx.out, &PaymentsView(resp))
}

Expand Down
3 changes: 2 additions & 1 deletion src/commands/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde::Serialize;
use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, set_header_bold, write_table, Render};
use crate::retry::retrying;

#[derive(Debug, ClapArgs)]
pub struct Args {
Expand All @@ -28,7 +29,7 @@ pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
}

async fn list(ctx: Ctx) -> Result<(), CliError> {
let resp = ctx.sdk.admin.list_chains().await?;
let resp = retrying(ctx.global.retries, || ctx.sdk.admin.list_chains()).await?;
crate::output::emit(&ctx.out, &ChainsView(resp))
}

Expand Down
20 changes: 20 additions & 0 deletions src/commands/endpoint/bulk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use quicknode_sdk::admin::{
};
use serde::Serialize;

use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity};
use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render};
Expand Down Expand Up @@ -73,6 +74,25 @@ async fn set_status(a: IdsArgs, status: &str, ctx: Ctx) -> Result<(), CliError>
if a.ids.is_empty() {
return Err(CliError::Arg("supply at least one endpoint id".to_string()));
}
// Pausing a fleet stops it serving requests — confirm with the blast
// radius spelled out. Resuming restores service, so it stays unguarded.
if status == "paused" {
let cfg = ConfirmCfg::new(
ctx.global.yes_count,
ctx.global.no_input,
ctx.out.stdout_is_tty,
);
let proceed = match decide_without_prompt(Severity::Mild, cfg)? {
true => true,
false => prompt_yes_no(&format!(
"Pause {} endpoint(s)? They will stop serving requests",
a.ids.len()
))?,
};
if !proceed {
return Err(CliError::Cancelled);
}
}
let req = BulkUpdateEndpointStatusRequest {
ids: a.ids,
status: status.to_string(),
Expand Down
Loading
Loading