From e5a3d35f8b2225f0851b21e4018bf140966008fd Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 8 Jun 2026 14:57:27 -0400 Subject: [PATCH 1/2] Deepen destructive-action seams: confirm + destroy modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight destructive command sites were each rebuilding the same confirmation ceremony (build ConfirmCfg → decide_without_prompt → optional prompt → NeedsConfirmation/Cancelled mapping), with `kv/mod.rs` already drifting into a local `confirm_mild` helper. Six of those sites also followed an identical shape around the confirm: prompt → SDK delete call → ✓ note. Two new seams: - `confirm::mild(&ctx, prompt)` and `confirm::severe_delete_all(&ctx, singular)` absorb the cfg + prompt + cancel-mapping. The lower-level primitives (`Severity`, `ConfirmCfg`, `decide_without_prompt`, `prompt_yes_no`, `prompt_typed`) are now `pub(crate)` so future call sites can't drift back into copy-pasting the ceremony. - New `destroy` module with `single` and `all` wraps the full destructive call: confirm → SDK closure → `✓ Deleted …` note. Generic over the SDK response type so each resource passes its own delete fn. Converted: tag delete, team delete, webhook delete/delete_all, stream delete/delete_all, kv set/list delete. endpoint archive stays on `confirm::mild` (not a delete). All existing wording preserved byte-for-byte (kv keys still print quoted; webhook/stream/tag/team ids still print bare). 173 tests pass, clippy clean, fmt clean. --- src/commands/endpoint/mod.rs | 15 ++------ src/commands/kv/actions.rs | 16 +++++---- src/commands/kv/mod.rs | 17 --------- src/commands/stream/actions.rs | 39 +++------------------ src/commands/tag.rs | 22 ++++-------- src/commands/team.rs | 19 ++-------- src/commands/webhook/actions.rs | 42 +++++----------------- src/confirm.rs | 60 +++++++++++++++++++++++++++---- src/destroy.rs | 62 +++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 10 files changed, 151 insertions(+), 142 deletions(-) create mode 100644 src/destroy.rs diff --git a/src/commands/endpoint/mod.rs b/src/commands/endpoint/mod.rs index 69ec8f3..1ca7166 100644 --- a/src/commands/endpoint/mod.rs +++ b/src/commands/endpoint/mod.rs @@ -6,7 +6,7 @@ use quicknode_sdk::admin::{ UpdateEndpointRequest, UpdateEndpointStatusRequest, }; -use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity}; +use crate::confirm; use crate::context::Ctx; use crate::errors::CliError; use crate::time_arg; @@ -266,18 +266,7 @@ async fn update(a: UpdateArgs, ctx: Ctx) -> Result<(), CliError> { } async fn archive(a: ArchiveArgs, ctx: Ctx) -> Result<(), CliError> { - 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!("Archive endpoint {}?", a.id))?, - }; - if !proceed { - return Err(CliError::Cancelled); - } + confirm::mild(&ctx, &format!("Archive endpoint {}?", a.id))?; ctx.sdk.admin.archive_endpoint(&a.id).await?; ctx.out.note(&format!("✓ Archived endpoint {}", a.id)); Ok(()) diff --git a/src/commands/kv/actions.rs b/src/commands/kv/actions.rs index 3cfe808..80fd490 100644 --- a/src/commands/kv/actions.rs +++ b/src/commands/kv/actions.rs @@ -49,9 +49,11 @@ pub(super) async fn set(cmd: SetCmd, ctx: Ctx) -> Result<(), CliError> { crate::output::emit(&ctx.out, &SetsView(resp))?; } SetCmd::Delete { key } => { - super::confirm_mild(&ctx, &format!("Delete set {key:?}?"))?; - ctx.sdk.kvstore.delete_set(&key).await?; - ctx.out.note(&format!("✓ Deleted set {key:?}")); + let kvstore = &ctx.sdk.kvstore; + crate::destroy::single(&ctx, "set", &format!("{key:?}"), || { + kvstore.delete_set(&key) + }) + .await?; } SetCmd::Bulk(a) => { if a.add.is_empty() && a.delete.is_empty() { @@ -165,9 +167,11 @@ pub(super) async fn list(cmd: ListCmd, ctx: Ctx) -> Result<(), CliError> { ctx.out.note(&format!("✓ Updated list {:?}", a.key)); } ListCmd::Delete { key } => { - super::confirm_mild(&ctx, &format!("Delete list {key:?}?"))?; - ctx.sdk.kvstore.delete_list(&key).await?; - ctx.out.note(&format!("✓ Deleted list {key:?}")); + let kvstore = &ctx.sdk.kvstore; + crate::destroy::single(&ctx, "list", &format!("{key:?}"), || { + kvstore.delete_list(&key) + }) + .await?; } } Ok(()) diff --git a/src/commands/kv/mod.rs b/src/commands/kv/mod.rs index 1ec815f..834c5f5 100644 --- a/src/commands/kv/mod.rs +++ b/src/commands/kv/mod.rs @@ -7,7 +7,6 @@ use std::io::{IsTerminal, Read}; use clap::{Args as ClapArgs, Subcommand}; -use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity}; use crate::context::Ctx; use crate::errors::CliError; @@ -114,22 +113,6 @@ pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { } } -fn confirm_mild(ctx: &Ctx, prompt: &str) -> Result<(), CliError> { - 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(prompt)?, - }; - if !proceed { - return Err(CliError::Cancelled); - } - Ok(()) -} - fn read_stdin() -> Result { if std::io::stdin().is_terminal() { return Err(CliError::Arg( diff --git a/src/commands/stream/actions.rs b/src/commands/stream/actions.rs index 622a470..b338f8a 100644 --- a/src/commands/stream/actions.rs +++ b/src/commands/stream/actions.rs @@ -9,8 +9,8 @@ use quicknode_sdk::streams::{ use super::render::{StreamView, StreamsListView, TestFilterView}; use super::{CreateArgs, ListArgs, TestFilterArgs, UpdateArgs}; -use crate::confirm::{decide_without_prompt, prompt_typed, prompt_yes_no, ConfirmCfg, Severity}; use crate::context::Ctx; +use crate::destroy; use crate::errors::CliError; pub(super) async fn list(a: ListArgs, ctx: Ctx) -> Result<(), CliError> { @@ -130,42 +130,13 @@ pub(super) async fn update(a: UpdateArgs, ctx: Ctx) -> Result<(), CliError> { } pub(super) async fn delete(id: &str, ctx: Ctx) -> Result<(), CliError> { - 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!("Delete stream {id}?"))?, - }; - if !proceed { - return Err(CliError::Cancelled); - } - ctx.sdk.streams.delete_stream(id).await?; - ctx.out.note(&format!("✓ Deleted stream {id}")); - Ok(()) + let streams = &ctx.sdk.streams; + destroy::single(&ctx, "stream", id, || streams.delete_stream(id)).await } pub(super) async fn delete_all(ctx: Ctx) -> Result<(), CliError> { - let cfg = ConfirmCfg::new( - ctx.global.yes_count, - ctx.global.no_input, - ctx.out.stdout_is_tty, - ); - let proceed = match decide_without_prompt(Severity::Severe, cfg)? { - true => true, - false => prompt_typed( - "Type 'delete-all' to delete EVERY stream on the account", - "delete-all", - )?, - }; - if !proceed { - return Err(CliError::Cancelled); - } - ctx.sdk.streams.delete_all_streams().await?; - ctx.out.note("✓ Deleted all streams"); - Ok(()) + let streams = &ctx.sdk.streams; + destroy::all(&ctx, "stream", "streams", || streams.delete_all_streams()).await } pub(super) async fn activate(id: &str, ctx: Ctx) -> Result<(), CliError> { diff --git a/src/commands/tag.rs b/src/commands/tag.rs index 444a5e8..6ae3620 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -5,8 +5,8 @@ use comfy_table::Cell; use quicknode_sdk::admin::RenameTagRequest; use serde::Serialize; -use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity}; use crate::context::Ctx; +use crate::destroy; use crate::errors::CliError; use crate::output::{new_table, set_header_bold, write_table, Render}; @@ -58,21 +58,11 @@ async fn rename(tag_id: i32, label: String, ctx: Ctx) -> Result<(), CliError> { } async fn delete(id: i32, ctx: Ctx) -> Result<(), CliError> { - 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!("Delete tag {id}?"))?, - }; - if !proceed { - return Err(CliError::Cancelled); - } - ctx.sdk.admin.delete_account_tag(id).await?; - ctx.out.note(&format!("✓ Deleted tag {id}")); - Ok(()) + let admin = &ctx.sdk.admin; + destroy::single(&ctx, "tag", &id.to_string(), || { + admin.delete_account_tag(id) + }) + .await } #[derive(Serialize)] diff --git a/src/commands/team.rs b/src/commands/team.rs index 7b78dfe..a13e7c7 100644 --- a/src/commands/team.rs +++ b/src/commands/team.rs @@ -7,8 +7,8 @@ use quicknode_sdk::admin::{ }; use serde::Serialize; -use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity}; use crate::context::Ctx; +use crate::destroy; use crate::errors::CliError; use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render}; @@ -142,21 +142,8 @@ async fn show(id: i64, ctx: Ctx) -> Result<(), CliError> { } async fn delete(id: i64, ctx: Ctx) -> Result<(), CliError> { - 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!("Delete team {id}?"))?, - }; - if !proceed { - return Err(CliError::Cancelled); - } - ctx.sdk.admin.delete_team(id).await?; - ctx.out.note(&format!("✓ Deleted team {id}")); - Ok(()) + let admin = &ctx.sdk.admin; + destroy::single(&ctx, "team", &id.to_string(), || admin.delete_team(id)).await } async fn endpoints(id: i64, ctx: Ctx) -> Result<(), CliError> { diff --git a/src/commands/webhook/actions.rs b/src/commands/webhook/actions.rs index 51e944f..8276c17 100644 --- a/src/commands/webhook/actions.rs +++ b/src/commands/webhook/actions.rs @@ -12,8 +12,8 @@ use quicknode_sdk::webhooks::{ use super::render::{WebhookView, WebhooksListView}; use super::{ActivateArgs, CreateArgs, ListArgs, TemplateKind, UpdateArgs, UpdateTemplateArgs}; -use crate::confirm::{decide_without_prompt, prompt_typed, prompt_yes_no, ConfirmCfg, Severity}; use crate::context::Ctx; +use crate::destroy; use crate::errors::CliError; pub(super) async fn list(a: ListArgs, ctx: Ctx) -> Result<(), CliError> { @@ -126,42 +126,16 @@ pub(super) async fn update_template(a: UpdateTemplateArgs, ctx: Ctx) -> Result<( } pub(super) async fn delete(id: &str, ctx: Ctx) -> Result<(), CliError> { - 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!("Delete webhook {id}?"))?, - }; - if !proceed { - return Err(CliError::Cancelled); - } - ctx.sdk.webhooks.delete_webhook(id).await?; - ctx.out.note(&format!("✓ Deleted webhook {id}")); - Ok(()) + let webhooks = &ctx.sdk.webhooks; + destroy::single(&ctx, "webhook", id, || webhooks.delete_webhook(id)).await } pub(super) async fn delete_all(ctx: Ctx) -> Result<(), CliError> { - let cfg = ConfirmCfg::new( - ctx.global.yes_count, - ctx.global.no_input, - ctx.out.stdout_is_tty, - ); - let proceed = match decide_without_prompt(Severity::Severe, cfg)? { - true => true, - false => prompt_typed( - "Type 'delete-all' to delete EVERY webhook on the account", - "delete-all", - )?, - }; - if !proceed { - return Err(CliError::Cancelled); - } - ctx.sdk.webhooks.delete_all_webhooks().await?; - ctx.out.note("✓ Deleted all webhooks"); - Ok(()) + let webhooks = &ctx.sdk.webhooks; + destroy::all(&ctx, "webhook", "webhooks", || { + webhooks.delete_all_webhooks() + }) + .await } pub(super) async fn activate(a: ActivateArgs, ctx: Ctx) -> Result<(), CliError> { diff --git a/src/confirm.rs b/src/confirm.rs index 97883f2..83e483a 100644 --- a/src/confirm.rs +++ b/src/confirm.rs @@ -6,32 +6,45 @@ //! //! On a non-TTY, mild requires `--yes` and severe requires `--yes --yes` — never //! auto-confirm in scripts. +//! +//! Commands call [`mild`] or [`severe_delete_all`]; the lower-level primitives +//! are `pub(crate)` so call sites can't drift back into copy-pasting the +//! decide → prompt → map_err ceremony. +use crate::context::Ctx; use crate::errors::CliError; /// What kind of confirmation a command needs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Severity { +pub(crate) enum Severity { Mild, Severe, } /// Configuration captured from CLI flags + TTY detection. #[derive(Debug, Clone, Copy)] -pub struct ConfirmCfg { +pub(crate) struct ConfirmCfg { pub yes_count: u8, pub no_input: bool, pub is_tty: bool, } impl ConfirmCfg { - pub fn new(yes_count: u8, no_input: bool, is_tty: bool) -> Self { + pub(crate) fn new(yes_count: u8, no_input: bool, is_tty: bool) -> Self { Self { yes_count, no_input, is_tty, } } + + fn from_ctx(ctx: &Ctx) -> Self { + Self::new( + ctx.global.yes_count, + ctx.global.no_input, + ctx.out.stdout_is_tty, + ) + } } /// Decide whether to proceed *without* prompting. @@ -40,7 +53,7 @@ impl ConfirmCfg { /// - `Ok(true)` → proceed, no prompt needed /// - `Ok(false)` → caller should prompt the user (only possible on TTY w/o --no-input) /// - `Err(NeedsConfirmation)` → cannot proceed (script mode without enough --yes) -pub fn decide_without_prompt(severity: Severity, cfg: ConfirmCfg) -> Result { +pub(crate) fn decide_without_prompt(severity: Severity, cfg: ConfirmCfg) -> Result { let required = match severity { Severity::Mild => 1, Severity::Severe => 2, @@ -55,7 +68,7 @@ pub fn decide_without_prompt(severity: Severity, cfg: ConfirmCfg) -> Result Result { +pub(crate) fn prompt_yes_no(message: &str) -> Result { use dialoguer::Confirm; Confirm::new() .with_prompt(message) @@ -66,7 +79,7 @@ pub fn prompt_yes_no(message: &str) -> Result { /// Interactive typed-word confirmation. Returns true if the user types /// `expected` exactly. -pub fn prompt_typed(message: &str, expected: &str) -> Result { +pub(crate) fn prompt_typed(message: &str, expected: &str) -> Result { use dialoguer::Input; let typed: String = Input::new() .with_prompt(message) @@ -76,6 +89,41 @@ pub fn prompt_typed(message: &str, expected: &str) -> Result { Ok(typed == expected) } +/// Mild confirmation gate: returns `Ok(())` if the caller may proceed, or +/// `Err(Cancelled)` / `Err(NeedsConfirmation)` if not. One `--yes` skips the +/// prompt; on a TTY without `--yes` the user gets a y/N; on a non-TTY without +/// `--yes` the call returns `NeedsConfirmation` (exit code 5). +pub fn mild(ctx: &Ctx, prompt: &str) -> Result<(), CliError> { + let cfg = ConfirmCfg::from_ctx(ctx); + let proceed = match decide_without_prompt(Severity::Mild, cfg)? { + true => true, + false => prompt_yes_no(prompt)?, + }; + if !proceed { + return Err(CliError::Cancelled); + } + Ok(()) +} + +/// Severe confirmation gate for the `delete-all` family. Requires the user +/// to type the literal word `delete-all`, or `--yes --yes` to skip. The +/// prompt is uniform: `"Type 'delete-all' to delete EVERY {singular_resource} +/// on the account"`. +pub fn severe_delete_all(ctx: &Ctx, singular_resource: &str) -> Result<(), CliError> { + let cfg = ConfirmCfg::from_ctx(ctx); + let proceed = match decide_without_prompt(Severity::Severe, cfg)? { + true => true, + false => prompt_typed( + &format!("Type 'delete-all' to delete EVERY {singular_resource} on the account"), + "delete-all", + )?, + }; + if !proceed { + return Err(CliError::Cancelled); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/destroy.rs b/src/destroy.rs new file mode 100644 index 0000000..b010565 --- /dev/null +++ b/src/destroy.rs @@ -0,0 +1,62 @@ +//! Destructive SDK calls with confirmation + success note. +//! +//! Builds on [`crate::confirm`] (which owns the *decision* — prompt, severity, +//! exit code) by adding the *execution* layer: prompt, then call the SDK, then +//! emit a uniform "✓ Deleted …" note. Commands hand in the SDK closure and the +//! resource label; the helper does the rest. +//! +//! Two entry points: +//! - [`single`]: one-resource delete (mild confirmation). +//! - [`all`]: bulk delete-all (severe `delete-all` confirmation). + +use std::future::Future; + +use quicknode_sdk::errors::SdkError; + +use crate::confirm; +use crate::context::Ctx; +use crate::errors::CliError; + +/// Single-resource destructive call. Mild confirmation; one `--yes` skips. +/// +/// `resource` is the human label used in both prompt and note (e.g. `"webhook"`, +/// `"stream"`, `"tag"`, `"set"`, `"list"`). `id_display` is the pre-formatted +/// id — callers using URL-safe ids pass them raw; callers using free-form +/// strings (e.g. kv keys) pre-quote via `&format!("{key:?}")` so the prompt +/// and note disambiguate spaces / specials. +pub async fn single( + ctx: &Ctx, + resource: &str, + id_display: &str, + call: F, +) -> Result<(), CliError> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + confirm::mild(ctx, &format!("Delete {resource} {id_display}?"))?; + call().await?; + ctx.out.note(&format!("✓ Deleted {resource} {id_display}")); + Ok(()) +} + +/// Bulk delete-all destructive call. Severe confirmation; user must type +/// `delete-all` or pass `--yes --yes`. +/// +/// `resource_singular` lands in the prompt ("…delete EVERY {singular}…"), +/// `resource_plural` lands in the success note ("✓ Deleted all {plural}"). +pub async fn all( + ctx: &Ctx, + resource_singular: &str, + resource_plural: &str, + call: F, +) -> Result<(), CliError> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + confirm::severe_delete_all(ctx, resource_singular)?; + call().await?; + ctx.out.note(&format!("✓ Deleted all {resource_plural}")); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index f6bdb4d..2d8ae53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub(crate) mod commands; pub(crate) mod config; pub(crate) mod confirm; pub(crate) mod context; +pub(crate) mod destroy; pub(crate) mod time_arg; pub use cli::Cli; From f5ab828f69742298156cc472262151ae949c4d7c Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 8 Jun 2026 14:57:52 -0400 Subject: [PATCH 2/2] Gitignore CLAUDE.local.md Per-repo Claude notes (Linear team config, triage label mapping, domain docs pointers) live in CLAUDE.local.md so they stay out of this public repository. The detail docs already live under docs/agents/ which was gitignored previously. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 36c0dc3..03f57a6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ # Logs *.log +docs/agents/ + +# Local Claude notes (gitignored per-repo configuration) +CLAUDE.local.md