From 7cfbe085eaf66599a1b22fcb24760e2faf7f6178 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 8 Jun 2026 16:04:00 -0400 Subject: [PATCH] Nest `tag` and `bulk` under `qn endpoint` Top-level `qn tag` and `qn bulk` only ever operated on endpoints. Move them under `qn endpoint` to match the existing `endpoint security` and `endpoint rate-limit` nesting pattern. Hard break (pre-1.0): no aliases for the old paths. qn endpoint tag {list,rename,delete,add,remove} qn endpoint bulk {pause,resume} qn endpoint bulk tag {add,remove} Bulk status is split into `pause`/`resume` verbs (was a single `status` verb with `--status active|paused`) to mirror the single-endpoint commands. The `BulkUpdateEndpointStatusRequest.status` mapping is hardcoded in the two verb arms; a header comment documents the divergence from the SDK's free-string field. `tag delete`'s positional is renamed `id` -> `tag_id` to match `tag rename`. 173 tests pass; clippy + fmt clean. DX-5619 --- CLAUDE.md | 2 +- README.md | 4 +- src/cli.rs | 9 --- src/commands/{ => endpoint}/bulk.rs | 63 ++++++--------- src/commands/endpoint/mod.rs | 11 ++- src/commands/endpoint/tag.rs | 120 ++++++++++++++++++++++++---- src/commands/mod.rs | 2 - src/commands/tag.rs | 105 ------------------------ src/confirm.rs | 2 +- tests/admin_extra.rs | 25 ++++-- 10 files changed, 159 insertions(+), 184 deletions(-) rename src/commands/{ => endpoint}/bulk.rs (80%) delete mode 100644 src/commands/tag.rs diff --git a/CLAUDE.md b/CLAUDE.md index 221d281..ee240ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ src/ │ ├── security.rs │ ├── ratelimit.rs │ └── tag.rs - └── {tag,team,usage,metrics,chain,billing,bulk,stream,webhook,kv}.rs + └── {team,usage,metrics,chain,billing,stream,webhook,kv}.rs ``` ## Adding a new subcommand: the playbook diff --git a/README.md b/README.md index a85ab29..e97ef13 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,8 @@ qn usage by-endpoint --from 30d -o yaml qn metrics account --period day --metric credits_over_time qn chain list qn billing invoices -qn bulk status --status paused ep-1 ep-2 ep-3 -qn tag list +qn endpoint bulk pause ep-1 ep-2 ep-3 +qn endpoint tag list qn team list ``` diff --git a/src/cli.rs b/src/cli.rs index ef4d3d1..41aa5a8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -81,10 +81,6 @@ pub enum Command { #[command(visible_alias = "endpoints")] Endpoint(commands::endpoint::Args), - /// Manage account-level tags. - #[command(visible_alias = "tags")] - Tag(commands::tag::Args), - /// Manage teams. #[command(visible_alias = "teams")] Team(commands::team::Args), @@ -102,9 +98,6 @@ pub enum Command { /// View invoices and payments. Billing(commands::billing::Args), - /// Bulk operations across many endpoints. - Bulk(commands::bulk::Args), - /// Manage blockchain data streams. #[command(visible_alias = "streams")] Stream(commands::stream::Args), @@ -162,13 +155,11 @@ impl Cli { Command::Endpoint(args) => { commands::endpoint::run(args, Ctx::from_global(global)?).await } - Command::Tag(args) => commands::tag::run(args, Ctx::from_global(global)?).await, Command::Team(args) => commands::team::run(args, Ctx::from_global(global)?).await, Command::Usage(args) => commands::usage::run(args, Ctx::from_global(global)?).await, Command::Metrics(args) => commands::metrics::run(args, Ctx::from_global(global)?).await, Command::Chain(args) => commands::chain::run(args, Ctx::from_global(global)?).await, Command::Billing(args) => commands::billing::run(args, Ctx::from_global(global)?).await, - Command::Bulk(args) => commands::bulk::run(args, Ctx::from_global(global)?).await, Command::Stream(args) => commands::stream::run(args, Ctx::from_global(global)?).await, Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await, Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await, diff --git a/src/commands/bulk.rs b/src/commands/endpoint/bulk.rs similarity index 80% rename from src/commands/bulk.rs rename to src/commands/endpoint/bulk.rs index b0700e0..79128a0 100644 --- a/src/commands/bulk.rs +++ b/src/commands/endpoint/bulk.rs @@ -1,6 +1,12 @@ -//! `qn bulk …` — bulk operations across many endpoints. - -use clap::{Args as ClapArgs, Subcommand, ValueEnum}; +//! `qn endpoint bulk …` — bulk operations across many endpoints. +//! +//! Note on the SDK mapping: the SDK exposes a single +//! `bulk_update_endpoint_status(ids, status)` call where `status` is a free-form +//! string. We split this into two CLI verbs (`pause` / `resume`) to mirror the +//! single-endpoint `qn endpoint pause` / `qn endpoint resume` verbs. The mapping is: +//! `pause -> "paused"`, `resume -> "active"`. + +use clap::{Args as ClapArgs, Subcommand}; use comfy_table::Cell; use quicknode_sdk::admin::{ BulkAddTagRequest, BulkRemoveTagRequest, BulkUpdateEndpointStatusRequest, @@ -11,47 +17,25 @@ use crate::context::Ctx; use crate::errors::CliError; use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render}; -#[derive(Debug, ClapArgs)] -pub struct Args { - #[command(subcommand)] - pub cmd: BulkCmd, -} - #[derive(Debug, Subcommand)] pub enum BulkCmd { - /// Activate or pause many endpoints at once. - Status(StatusArgs), + /// Pause many endpoints at once. + Pause(IdsArgs), + /// Resume (activate) many endpoints at once. + Resume(IdsArgs), /// Manage tags on many endpoints at once. #[command(subcommand)] - Tag(TagCmd), + Tag(BulkTagCmd), } #[derive(Debug, ClapArgs)] -pub struct StatusArgs { - /// Target status. - #[arg(long, value_enum)] - pub status: BulkStatus, +pub struct IdsArgs { /// Endpoint ids. pub ids: Vec, } -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum BulkStatus { - Active, - Paused, -} - -impl BulkStatus { - fn as_str(self) -> &'static str { - match self { - BulkStatus::Active => "active", - BulkStatus::Paused => "paused", - } - } -} - #[derive(Debug, Subcommand)] -pub enum TagCmd { +pub enum BulkTagCmd { /// Apply a tag to many endpoints (creates the tag if missing). Add(AddTagArgs), /// Remove a tag from many endpoints (by numeric tag id). @@ -76,21 +60,22 @@ pub struct RemoveTagArgs { pub ids: Vec, } -pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { - match args.cmd { - BulkCmd::Status(a) => status(a, ctx).await, - BulkCmd::Tag(TagCmd::Add(a)) => tag_add(a, ctx).await, - BulkCmd::Tag(TagCmd::Remove(a)) => tag_remove(a, ctx).await, +pub async fn run(cmd: BulkCmd, ctx: Ctx) -> Result<(), CliError> { + match cmd { + BulkCmd::Pause(a) => set_status(a, "paused", ctx).await, + BulkCmd::Resume(a) => set_status(a, "active", ctx).await, + BulkCmd::Tag(BulkTagCmd::Add(a)) => tag_add(a, ctx).await, + BulkCmd::Tag(BulkTagCmd::Remove(a)) => tag_remove(a, ctx).await, } } -async fn status(a: StatusArgs, ctx: Ctx) -> Result<(), CliError> { +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())); } let req = BulkUpdateEndpointStatusRequest { ids: a.ids, - status: a.status.as_str().to_string(), + status: status.to_string(), }; let resp = ctx.sdk.admin.bulk_update_endpoint_status(&req).await?; crate::output::emit(&ctx.out, &BulkStatusView(resp)) diff --git a/src/commands/endpoint/mod.rs b/src/commands/endpoint/mod.rs index 69ec8f3..1b94edf 100644 --- a/src/commands/endpoint/mod.rs +++ b/src/commands/endpoint/mod.rs @@ -11,11 +11,13 @@ use crate::context::Ctx; use crate::errors::CliError; use crate::time_arg; +mod bulk; mod ratelimit; pub(crate) mod render; mod security; mod tag; +pub use bulk::BulkCmd; pub use ratelimit::RateLimitCmd; pub use security::SecurityCmd; pub use tag::TagCmd; @@ -61,8 +63,8 @@ pub enum EndpointCmd { /// Disable multichain on an endpoint. DisableMultichain { id: String }, - /// Manage tags on an endpoint (use `qn tag` for account-wide tag management). - #[command(subcommand)] + /// Manage endpoint tags (per-endpoint add/remove and account-wide list/rename/delete). + #[command(subcommand, visible_alias = "tags")] Tag(TagCmd), /// Manage endpoint security settings (tokens, referrers, IPs, JWTs, ...). @@ -72,6 +74,10 @@ pub enum EndpointCmd { /// Manage rate limits on an endpoint. #[command(subcommand)] RateLimit(RateLimitCmd), + + /// Bulk operations across many endpoints. + #[command(subcommand)] + Bulk(BulkCmd), } #[derive(Debug, ClapArgs)] @@ -190,6 +196,7 @@ pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { EndpointCmd::Tag(c) => tag::run(c, ctx).await, EndpointCmd::Security(c) => security::run(c, ctx).await, EndpointCmd::RateLimit(c) => ratelimit::run(c, ctx).await, + EndpointCmd::Bulk(c) => bulk::run(c, ctx).await, } } diff --git a/src/commands/endpoint/tag.rs b/src/commands/endpoint/tag.rs index e15376d..2a41cd9 100644 --- a/src/commands/endpoint/tag.rs +++ b/src/commands/endpoint/tag.rs @@ -1,15 +1,36 @@ -//! `qn endpoint tag {add,remove}` — per-endpoint tag management. +//! `qn endpoint tag …` — endpoint tag management. //! -//! For account-wide tag list/rename/delete, see `qn tag`. +//! Covers both account-wide tag CRUD (`list`, `rename`, `delete`) and +//! per-endpoint tag operations (`add`, `remove`). Tags only exist to label +//! endpoints, which is why the account-wide CRUD lives here too. use clap::Subcommand; -use quicknode_sdk::admin::CreateTagRequest; +use comfy_table::Cell; +use quicknode_sdk::admin::{CreateTagRequest, RenameTagRequest}; +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, Render}; #[derive(Debug, Subcommand)] pub enum TagCmd { + /// List every tag on the account with usage counts. + #[command(visible_alias = "ls")] + List, + /// Rename a tag. + Rename { + /// Tag id (numeric). + tag_id: i32, + /// New label. + label: String, + }, + /// Delete a tag. The tag must not be applied to any endpoint. + Delete { + /// Tag id (numeric). + tag_id: i32, + }, /// Tag an endpoint. Creates the tag on the account if missing. Add { /// Endpoint id. @@ -17,7 +38,7 @@ pub enum TagCmd { /// Tag label. label: String, }, - /// Remove a tag from an endpoint. `tag_id` is the numeric tag id from `qn tag list`. + /// Remove a tag from an endpoint. `tag_id` is the numeric tag id from `qn endpoint tag list`. Remove { /// Endpoint id. id: String, @@ -28,17 +49,86 @@ pub enum TagCmd { pub async fn run(cmd: TagCmd, ctx: Ctx) -> Result<(), CliError> { match cmd { - TagCmd::Add { id, label } => { - let req = CreateTagRequest { - label: Some(label.clone()), - }; - ctx.sdk.admin.create_tag(&id, &req).await?; - ctx.out.note(&format!("✓ Tagged {id} with {label:?}")); - } - TagCmd::Remove { id, tag_id } => { - ctx.sdk.admin.delete_tag(&id, &tag_id).await?; - ctx.out.note(&format!("✓ Removed tag {tag_id} from {id}")); - } + TagCmd::List => list(ctx).await, + TagCmd::Rename { tag_id, label } => rename(tag_id, label, ctx).await, + TagCmd::Delete { tag_id } => delete(tag_id, ctx).await, + TagCmd::Add { id, label } => add(id, label, ctx).await, + TagCmd::Remove { id, tag_id } => remove(id, tag_id, ctx).await, + } +} + +async fn list(ctx: Ctx) -> Result<(), CliError> { + let resp = ctx.sdk.admin.list_tags().await?; + crate::output::emit(&ctx.out, &TagsView(resp)) +} + +async fn rename(tag_id: i32, label: String, ctx: Ctx) -> Result<(), CliError> { + let req = RenameTagRequest { + label: label.clone(), + }; + ctx.sdk.admin.rename_tag(tag_id, &req).await?; + ctx.out.note(&format!("✓ Renamed tag {tag_id} → {label:?}")); + Ok(()) +} + +async fn delete(tag_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 {tag_id}?"))?, + }; + if !proceed { + return Err(CliError::Cancelled); } + ctx.sdk.admin.delete_account_tag(tag_id).await?; + ctx.out.note(&format!("✓ Deleted tag {tag_id}")); + Ok(()) +} + +async fn add(id: String, label: String, ctx: Ctx) -> Result<(), CliError> { + let req = CreateTagRequest { + label: Some(label.clone()), + }; + ctx.sdk.admin.create_tag(&id, &req).await?; + ctx.out.note(&format!("✓ Tagged {id} with {label:?}")); + Ok(()) +} + +async fn remove(id: String, tag_id: String, ctx: Ctx) -> Result<(), CliError> { + ctx.sdk.admin.delete_tag(&id, &tag_id).await?; + ctx.out.note(&format!("✓ Removed tag {tag_id} from {id}")); Ok(()) } + +#[derive(Serialize)] +struct TagsView(quicknode_sdk::admin::ListTagsResponse); + +impl Render for TagsView { + fn render_table( + &self, + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + ) -> std::io::Result<()> { + let data = match &self.0.data { + Some(d) => d, + None => { + writeln!(w, "(no tag data)")?; + return Ok(()); + } + }; + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["ID", "LABEL", "USAGE"]); + for tg in &data.tags { + t.add_row(vec![ + Cell::new(tg.id), + Cell::new(&tg.label), + Cell::new(tg.usage_count), + ]); + } + write_table(w, &t) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fe10c29..89255e3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,13 +5,11 @@ pub mod auth; pub mod billing; -pub mod bulk; pub mod chain; pub mod endpoint; pub mod kv; pub mod metrics; pub mod stream; -pub mod tag; pub mod team; pub mod usage; pub mod webhook; diff --git a/src/commands/tag.rs b/src/commands/tag.rs deleted file mode 100644 index 444a5e8..0000000 --- a/src/commands/tag.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! `qn tag …` — account-wide tag management. - -use clap::{Args as ClapArgs, Subcommand}; -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::errors::CliError; -use crate::output::{new_table, set_header_bold, write_table, Render}; - -#[derive(Debug, ClapArgs)] -pub struct Args { - #[command(subcommand)] - pub cmd: TagCmd, -} - -#[derive(Debug, Subcommand)] -pub enum TagCmd { - /// List every tag on the account with usage counts. - #[command(visible_alias = "ls")] - List, - /// Rename a tag. - Rename { - /// Tag id (numeric). - tag_id: i32, - /// New label. - label: String, - }, - /// Delete a tag. The tag must not be applied to any endpoint. - Delete { - /// Tag id (numeric). - id: i32, - }, -} - -pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { - match args.cmd { - TagCmd::List => list(ctx).await, - TagCmd::Rename { tag_id, label } => rename(tag_id, label, ctx).await, - TagCmd::Delete { id } => delete(id, ctx).await, - } -} - -async fn list(ctx: Ctx) -> Result<(), CliError> { - let resp = ctx.sdk.admin.list_tags().await?; - crate::output::emit(&ctx.out, &TagsView(resp)) -} - -async fn rename(tag_id: i32, label: String, ctx: Ctx) -> Result<(), CliError> { - let req = RenameTagRequest { - label: label.clone(), - }; - ctx.sdk.admin.rename_tag(tag_id, &req).await?; - ctx.out.note(&format!("✓ Renamed tag {tag_id} → {label:?}")); - Ok(()) -} - -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(()) -} - -#[derive(Serialize)] -struct TagsView(quicknode_sdk::admin::ListTagsResponse); - -impl Render for TagsView { - fn render_table( - &self, - w: &mut dyn std::io::Write, - ctx: &crate::output::OutputCtx, - ) -> std::io::Result<()> { - let data = match &self.0.data { - Some(d) => d, - None => { - writeln!(w, "(no tag data)")?; - return Ok(()); - } - }; - let mut t = new_table(ctx); - set_header_bold(&mut t, ctx, vec!["ID", "LABEL", "USAGE"]); - for tg in &data.tags { - t.add_row(vec![ - Cell::new(tg.id), - Cell::new(&tg.label), - Cell::new(tg.usage_count), - ]); - } - write_table(w, &t) - } -} diff --git a/src/confirm.rs b/src/confirm.rs index 97883f2..f6a6f76 100644 --- a/src/confirm.rs +++ b/src/confirm.rs @@ -1,7 +1,7 @@ //! Destructive-action confirmation helpers. //! //! Two levels: -//! - **mild** (e.g. `endpoint archive`, `tag delete`): one y/N prompt; --yes skips. +//! - **mild** (e.g. `endpoint archive`, `endpoint tag delete`): one y/N prompt; --yes skips. //! - **severe** (e.g. `stream delete-all`): typed-word confirmation; --yes --yes (twice) skips. //! //! On a non-TTY, mild requires `--yes` and severe requires `--yes --yes` — never diff --git a/tests/admin_extra.rs b/tests/admin_extra.rs index fdccbb0..d6df670 100644 --- a/tests/admin_extra.rs +++ b/tests/admin_extra.rs @@ -1,5 +1,5 @@ -//! Stage-3 admin-surface integration tests (tags, teams, usage, metrics, chain, -//! billing, bulk). +//! Stage-3 admin-surface integration tests (endpoint tags/bulk, teams, usage, +//! metrics, chain, billing). mod common; @@ -18,7 +18,12 @@ async fn tag_list() { }))) .mount(&server) .await; - assert_eq!(run_qn(&server.uri(), &["tag", "list"]).await.exit_code, 0); + assert_eq!( + run_qn(&server.uri(), &["endpoint", "tag", "list"]) + .await + .exit_code, + 0 + ); } #[tokio::test] @@ -32,7 +37,11 @@ async fn tag_rename_sends_label() { }))) .mount(&server) .await; - let out = run_qn(&server.uri(), &["tag", "rename", "42", "staging"]).await; + let out = run_qn( + &server.uri(), + &["endpoint", "tag", "rename", "42", "staging"], + ) + .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } @@ -46,9 +55,9 @@ async fn tag_delete_needs_yes() { }))) .mount(&server) .await; - let no = run_qn(&server.uri(), &["tag", "delete", "42"]).await; + let no = run_qn(&server.uri(), &["endpoint", "tag", "delete", "42"]).await; assert_eq!(no.exit_code, 5); - let yes = run_qn(&server.uri(), &["tag", "delete", "42", "--yes"]).await; + let yes = run_qn(&server.uri(), &["endpoint", "tag", "delete", "42", "--yes"]).await; assert_eq!(yes.exit_code, 0, "stderr={}", yes.stderr); } @@ -213,7 +222,7 @@ async fn bulk_status_paused() { .await; let out = run_qn( &server.uri(), - &["bulk", "status", "--status", "paused", "ep-1", "ep-2"], + &["endpoint", "bulk", "pause", "ep-1", "ep-2"], ) .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); @@ -232,7 +241,7 @@ async fn bulk_tag_add() { .await; let out = run_qn( &server.uri(), - &["bulk", "tag", "add", "--label", "prod", "ep-1"], + &["endpoint", "bulk", "tag", "add", "--label", "prod", "ep-1"], ) .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);