Skip to content

Commit b985a86

Browse files
committed
Remove account-wide delete-all verbs; gate remaining destructive ops
qn stream delete-all and qn webhook delete-all are gone. A one-line account-wide wipe is a disproportionate blast radius for a CLI command; that operation belongs behind the API where callers script it deliberately. Newly confirmation-gated (Mild: --yes skips, TTY prompts, scripts get exit 5 before any request is sent): - endpoint bulk pause — prompt includes the endpoint count - endpoint security token delete — notes that clients lose access - endpoint rate-limit delete-override — notes the revert to plan limits bulk resume stays ungated (it restores service). Existing prompts now state the blast radius (archive is irreversible from the CLI, tag delete is account-wide). The kv-local confirm_mild helper moved to confirm::confirm_mild for reuse. Severity::Severe and prompt_typed remain for future wide-blast-radius gates. CLAUDE.md now records the severity policy; README documents the confirmation model. Tests: each gated command verifies exit 5 with zero requests reaching the mock without --yes, and exit 0 with it.
1 parent 30be7c2 commit b985a86

21 files changed

Lines changed: 223 additions & 111 deletions

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,13 @@ Things to verify for each new endpoint:
7373
- **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.
7474
- **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.
7575
- **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`).
76-
- **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).
76+
- **Destructive operations**: the policy is non-negotiable —
77+
- 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.
78+
- 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.
79+
- **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.
80+
- Commands that restore service (`resume`, `activate`) are not gated.
81+
- `Severity::Severe` (typed-word + `--yes --yes`) exists in `confirm.rs` for future wide-blast-radius operations; nothing uses it today.
82+
- Every gated command needs both tests: no `--yes` non-TTY ⇒ exit 5 **and zero requests reach the mock** (`.expect(0)`); `--yes` ⇒ exit 0.
7783
- **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.
7884

7985
### 3. Module layout

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,17 @@ CLI hands the key to the SDK explicitly; it does not read the SDK's
212212
The hidden `--base-url <URL>` flag overrides the API host for all four
213213
sub-clients at once (used for integration tests and on-prem mirrors).
214214

215+
## Confirmations
216+
217+
Destructive commands (`delete`, `archive`, `bulk pause`, token revocation,
218+
removing a rate-limit override, …) prompt before acting, and the prompt states
219+
what will happen ("Pause 3 endpoint(s)? They will stop serving requests").
220+
Pass `--yes`/`-y` to skip the prompt. In scripts and CI (no TTY), a gated
221+
command without `--yes` exits with code 5 **before** any request is sent.
222+
223+
The CLI deliberately has no account-wide wipe commands (no `delete-all`);
224+
operations with that blast radius belong behind the API, not a one-liner.
225+
215226
## Retries
216227

217228
Read-only commands (`list`, `show`, `logs`, `metrics`, `usage`, …) retry
@@ -220,9 +231,9 @@ exponential backoff and full jitter. The default is 3 retries; tune it with
220231
the global `--retries <N>` flag (`--retries 0` disables).
221232

222233
Commands that modify resources (`create`, `update`, `delete`, `pause`, …)
223-
**never** retry automatically: the API has no idempotency keys yet, so a
224-
retried create could provision twice. If a mutation fails with a transient
225-
error, check whether it took effect before re-running it.
234+
**never** retry automatically: a retried create could provision twice. If a
235+
mutation fails with a transient error, check whether it took effect before
236+
re-running it.
226237

227238
## Exit codes
228239

@@ -233,7 +244,7 @@ error, check whether it took effect before re-running it.
233244
| 2 | API error (server returned 4xx/5xx) |
234245
| 3 | Network failure (timeout, connect, transport) |
235246
| 4 | Missing or invalid API key / config |
236-
| 5 | Operation needs confirmation (pass `--yes` or `--yes --yes`) |
247+
| 5 | Operation needs confirmation (pass `--yes`) |
237248
| 130 | Interrupted (SIGINT) |
238249

239250
## License

src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ pub struct Cli {
7070
#[arg(long, global = true, default_value_t = 3, value_name = "N")]
7171
pub retries: u32,
7272

73-
/// Skip confirmation prompts. Pass twice for destructive bulk operations like `stream delete-all`.
73+
/// Skip confirmation prompts on destructive operations.
7474
#[arg(short = 'y', long = "yes", global = true, action = ArgAction::Count)]
7575
pub yes: u8,
7676

src/commands/endpoint/bulk.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use quicknode_sdk::admin::{
1313
};
1414
use serde::Serialize;
1515

16+
use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity};
1617
use crate::context::Ctx;
1718
use crate::errors::CliError;
1819
use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render};
@@ -73,6 +74,25 @@ async fn set_status(a: IdsArgs, status: &str, ctx: Ctx) -> Result<(), CliError>
7374
if a.ids.is_empty() {
7475
return Err(CliError::Arg("supply at least one endpoint id".to_string()));
7576
}
77+
// Pausing a fleet stops it serving requests — confirm with the blast
78+
// radius spelled out. Resuming restores service, so it stays unguarded.
79+
if status == "paused" {
80+
let cfg = ConfirmCfg::new(
81+
ctx.global.yes_count,
82+
ctx.global.no_input,
83+
ctx.out.stdout_is_tty,
84+
);
85+
let proceed = match decide_without_prompt(Severity::Mild, cfg)? {
86+
true => true,
87+
false => prompt_yes_no(&format!(
88+
"Pause {} endpoint(s)? They will stop serving requests",
89+
a.ids.len()
90+
))?,
91+
};
92+
if !proceed {
93+
return Err(CliError::Cancelled);
94+
}
95+
}
7696
let req = BulkUpdateEndpointStatusRequest {
7797
ids: a.ids,
7898
status: status.to_string(),

src/commands/endpoint/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,10 @@ async fn archive(a: ArchiveArgs, ctx: Ctx) -> Result<(), CliError> {
281281
);
282282
let proceed = match decide_without_prompt(Severity::Mild, cfg)? {
283283
true => true,
284-
false => prompt_yes_no(&format!("Archive endpoint {}?", a.id))?,
284+
false => prompt_yes_no(&format!(
285+
"Archive endpoint {}? This cannot be undone from the CLI",
286+
a.id
287+
))?,
285288
};
286289
if !proceed {
287290
return Err(CliError::Cancelled);

src/commands/endpoint/ratelimit.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ pub async fn run(cmd: RateLimitCmd, ctx: Ctx) -> Result<(), CliError> {
100100
RateLimitCmd::Get { id } => get(&id, ctx).await,
101101
RateLimitCmd::Set(a) => set(a, ctx).await,
102102
RateLimitCmd::DeleteOverride { id, override_id } => {
103+
crate::confirm::confirm_mild(
104+
&ctx,
105+
&format!(
106+
"Delete rate-limit override {override_id} on {id}? \
107+
The endpoint reverts to its plan defaults"
108+
),
109+
)?;
103110
ctx.sdk
104111
.admin
105112
.delete_rate_limit_override(&id, &override_id)

src/commands/endpoint/security.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use quicknode_sdk::admin::{
1111
};
1212
use serde::Serialize;
1313

14+
use crate::confirm::confirm_mild;
1415
use crate::context::Ctx;
1516
use crate::errors::CliError;
1617
use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render};
@@ -265,6 +266,12 @@ async fn token(cmd: TokenCmd, ctx: Ctx) -> Result<(), CliError> {
265266
ctx.out.note(&format!("✓ Created token on {id}"));
266267
}
267268
TokenCmd::Delete { id, token_id } => {
269+
confirm_mild(
270+
&ctx,
271+
&format!(
272+
"Delete token {token_id} on {id}? Clients authenticating with it lose access"
273+
),
274+
)?;
268275
ctx.sdk.admin.delete_token(&id, &token_id).await?;
269276
ctx.out.note(&format!("✓ Deleted token {token_id} on {id}"));
270277
}

src/commands/endpoint/tag.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async fn delete(tag_id: i32, ctx: Ctx) -> Result<(), CliError> {
7979
);
8080
let proceed = match decide_without_prompt(Severity::Mild, cfg)? {
8181
true => true,
82-
false => prompt_yes_no(&format!("Delete tag {tag_id}?"))?,
82+
false => prompt_yes_no(&format!("Delete tag {tag_id} from the account?"))?,
8383
};
8484
if !proceed {
8585
return Err(CliError::Cancelled);

src/commands/kv/actions.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use quicknode_sdk::{
99

1010
use super::render::{ListView, ListsView, SetsView};
1111
use super::{ListCmd, SetCmd};
12+
use crate::confirm::confirm_mild;
1213
use crate::context::Ctx;
1314
use crate::errors::CliError;
1415
use crate::retry::retrying;
@@ -47,7 +48,7 @@ pub(super) async fn set(cmd: SetCmd, ctx: Ctx) -> Result<(), CliError> {
4748
crate::output::emit(&ctx.out, &SetsView(resp))?;
4849
}
4950
SetCmd::Delete { key } => {
50-
super::confirm_mild(&ctx, &format!("Delete set {key:?}?"))?;
51+
confirm_mild(&ctx, &format!("Delete set {key:?}?"))?;
5152
ctx.sdk.kvstore.delete_set(&key).await?;
5253
ctx.out.note(&format!("✓ Deleted set {key:?}"));
5354
}
@@ -160,7 +161,7 @@ pub(super) async fn list(cmd: ListCmd, ctx: Ctx) -> Result<(), CliError> {
160161
ctx.out.note(&format!("✓ Updated list {:?}", a.key));
161162
}
162163
ListCmd::Delete { key } => {
163-
super::confirm_mild(&ctx, &format!("Delete list {key:?}?"))?;
164+
confirm_mild(&ctx, &format!("Delete list {key:?}?"))?;
164165
ctx.sdk.kvstore.delete_list(&key).await?;
165166
ctx.out.note(&format!("✓ Deleted list {key:?}"));
166167
}

src/commands/kv/mod.rs

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ use std::io::{IsTerminal, Read};
77

88
use clap::{Args as ClapArgs, Subcommand};
99

10-
use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity};
1110
use crate::context::Ctx;
1211
use crate::errors::CliError;
1312

@@ -114,22 +113,6 @@ pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
114113
}
115114
}
116115

117-
fn confirm_mild(ctx: &Ctx, prompt: &str) -> Result<(), CliError> {
118-
let cfg = ConfirmCfg::new(
119-
ctx.global.yes_count,
120-
ctx.global.no_input,
121-
ctx.out.stdout_is_tty,
122-
);
123-
let proceed = match decide_without_prompt(Severity::Mild, cfg)? {
124-
true => true,
125-
false => prompt_yes_no(prompt)?,
126-
};
127-
if !proceed {
128-
return Err(CliError::Cancelled);
129-
}
130-
Ok(())
131-
}
132-
133116
fn read_stdin() -> Result<String, CliError> {
134117
if std::io::stdin().is_terminal() {
135118
return Err(CliError::Arg(

0 commit comments

Comments
 (0)