Skip to content

Commit 1a36ebb

Browse files
authored
Nest tag and bulk under qn endpoint (#10)
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
1 parent 3b5316e commit 1a36ebb

10 files changed

Lines changed: 159 additions & 184 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ src/
4343
│ ├── security.rs
4444
│ ├── ratelimit.rs
4545
│ └── tag.rs
46-
└── {tag,team,usage,metrics,chain,billing,bulk,stream,webhook,kv}.rs
46+
└── {team,usage,metrics,chain,billing,stream,webhook,kv}.rs
4747
```
4848

4949
## Adding a new subcommand: the playbook

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ qn usage by-endpoint --from 30d -o yaml
180180
qn metrics account --period day --metric credits_over_time
181181
qn chain list
182182
qn billing invoices
183-
qn bulk status --status paused ep-1 ep-2 ep-3
184-
qn tag list
183+
qn endpoint bulk pause ep-1 ep-2 ep-3
184+
qn endpoint tag list
185185
qn team list
186186
```
187187

src/cli.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,6 @@ pub enum Command {
8181
#[command(visible_alias = "endpoints")]
8282
Endpoint(commands::endpoint::Args),
8383

84-
/// Manage account-level tags.
85-
#[command(visible_alias = "tags")]
86-
Tag(commands::tag::Args),
87-
8884
/// Manage teams.
8985
#[command(visible_alias = "teams")]
9086
Team(commands::team::Args),
@@ -102,9 +98,6 @@ pub enum Command {
10298
/// View invoices and payments.
10399
Billing(commands::billing::Args),
104100

105-
/// Bulk operations across many endpoints.
106-
Bulk(commands::bulk::Args),
107-
108101
/// Manage blockchain data streams.
109102
#[command(visible_alias = "streams")]
110103
Stream(commands::stream::Args),
@@ -162,13 +155,11 @@ impl Cli {
162155
Command::Endpoint(args) => {
163156
commands::endpoint::run(args, Ctx::from_global(global)?).await
164157
}
165-
Command::Tag(args) => commands::tag::run(args, Ctx::from_global(global)?).await,
166158
Command::Team(args) => commands::team::run(args, Ctx::from_global(global)?).await,
167159
Command::Usage(args) => commands::usage::run(args, Ctx::from_global(global)?).await,
168160
Command::Metrics(args) => commands::metrics::run(args, Ctx::from_global(global)?).await,
169161
Command::Chain(args) => commands::chain::run(args, Ctx::from_global(global)?).await,
170162
Command::Billing(args) => commands::billing::run(args, Ctx::from_global(global)?).await,
171-
Command::Bulk(args) => commands::bulk::run(args, Ctx::from_global(global)?).await,
172163
Command::Stream(args) => commands::stream::run(args, Ctx::from_global(global)?).await,
173164
Command::Webhook(args) => commands::webhook::run(args, Ctx::from_global(global)?).await,
174165
Command::Kv(args) => commands::kv::run(args, Ctx::from_global(global)?).await,
Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
//! `qn bulk …` — bulk operations across many endpoints.
2-
3-
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
1+
//! `qn endpoint bulk …` — bulk operations across many endpoints.
2+
//!
3+
//! Note on the SDK mapping: the SDK exposes a single
4+
//! `bulk_update_endpoint_status(ids, status)` call where `status` is a free-form
5+
//! string. We split this into two CLI verbs (`pause` / `resume`) to mirror the
6+
//! single-endpoint `qn endpoint pause` / `qn endpoint resume` verbs. The mapping is:
7+
//! `pause -> "paused"`, `resume -> "active"`.
8+
9+
use clap::{Args as ClapArgs, Subcommand};
410
use comfy_table::Cell;
511
use quicknode_sdk::admin::{
612
BulkAddTagRequest, BulkRemoveTagRequest, BulkUpdateEndpointStatusRequest,
@@ -11,47 +17,25 @@ use crate::context::Ctx;
1117
use crate::errors::CliError;
1218
use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render};
1319

14-
#[derive(Debug, ClapArgs)]
15-
pub struct Args {
16-
#[command(subcommand)]
17-
pub cmd: BulkCmd,
18-
}
19-
2020
#[derive(Debug, Subcommand)]
2121
pub enum BulkCmd {
22-
/// Activate or pause many endpoints at once.
23-
Status(StatusArgs),
22+
/// Pause many endpoints at once.
23+
Pause(IdsArgs),
24+
/// Resume (activate) many endpoints at once.
25+
Resume(IdsArgs),
2426
/// Manage tags on many endpoints at once.
2527
#[command(subcommand)]
26-
Tag(TagCmd),
28+
Tag(BulkTagCmd),
2729
}
2830

2931
#[derive(Debug, ClapArgs)]
30-
pub struct StatusArgs {
31-
/// Target status.
32-
#[arg(long, value_enum)]
33-
pub status: BulkStatus,
32+
pub struct IdsArgs {
3433
/// Endpoint ids.
3534
pub ids: Vec<String>,
3635
}
3736

38-
#[derive(Debug, Clone, Copy, ValueEnum)]
39-
pub enum BulkStatus {
40-
Active,
41-
Paused,
42-
}
43-
44-
impl BulkStatus {
45-
fn as_str(self) -> &'static str {
46-
match self {
47-
BulkStatus::Active => "active",
48-
BulkStatus::Paused => "paused",
49-
}
50-
}
51-
}
52-
5337
#[derive(Debug, Subcommand)]
54-
pub enum TagCmd {
38+
pub enum BulkTagCmd {
5539
/// Apply a tag to many endpoints (creates the tag if missing).
5640
Add(AddTagArgs),
5741
/// Remove a tag from many endpoints (by numeric tag id).
@@ -76,21 +60,22 @@ pub struct RemoveTagArgs {
7660
pub ids: Vec<String>,
7761
}
7862

79-
pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
80-
match args.cmd {
81-
BulkCmd::Status(a) => status(a, ctx).await,
82-
BulkCmd::Tag(TagCmd::Add(a)) => tag_add(a, ctx).await,
83-
BulkCmd::Tag(TagCmd::Remove(a)) => tag_remove(a, ctx).await,
63+
pub async fn run(cmd: BulkCmd, ctx: Ctx) -> Result<(), CliError> {
64+
match cmd {
65+
BulkCmd::Pause(a) => set_status(a, "paused", ctx).await,
66+
BulkCmd::Resume(a) => set_status(a, "active", ctx).await,
67+
BulkCmd::Tag(BulkTagCmd::Add(a)) => tag_add(a, ctx).await,
68+
BulkCmd::Tag(BulkTagCmd::Remove(a)) => tag_remove(a, ctx).await,
8469
}
8570
}
8671

87-
async fn status(a: StatusArgs, ctx: Ctx) -> Result<(), CliError> {
72+
async fn set_status(a: IdsArgs, status: &str, ctx: Ctx) -> Result<(), CliError> {
8873
if a.ids.is_empty() {
8974
return Err(CliError::Arg("supply at least one endpoint id".to_string()));
9075
}
9176
let req = BulkUpdateEndpointStatusRequest {
9277
ids: a.ids,
93-
status: a.status.as_str().to_string(),
78+
status: status.to_string(),
9479
};
9580
let resp = ctx.sdk.admin.bulk_update_endpoint_status(&req).await?;
9681
crate::output::emit(&ctx.out, &BulkStatusView(resp))

src/commands/endpoint/mod.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ use crate::context::Ctx;
1111
use crate::errors::CliError;
1212
use crate::time_arg;
1313

14+
mod bulk;
1415
mod ratelimit;
1516
pub(crate) mod render;
1617
mod security;
1718
mod tag;
1819

20+
pub use bulk::BulkCmd;
1921
pub use ratelimit::RateLimitCmd;
2022
pub use security::SecurityCmd;
2123
pub use tag::TagCmd;
@@ -61,8 +63,8 @@ pub enum EndpointCmd {
6163
/// Disable multichain on an endpoint.
6264
DisableMultichain { id: String },
6365

64-
/// Manage tags on an endpoint (use `qn tag` for account-wide tag management).
65-
#[command(subcommand)]
66+
/// Manage endpoint tags (per-endpoint add/remove and account-wide list/rename/delete).
67+
#[command(subcommand, visible_alias = "tags")]
6668
Tag(TagCmd),
6769

6870
/// Manage endpoint security settings (tokens, referrers, IPs, JWTs, ...).
@@ -72,6 +74,10 @@ pub enum EndpointCmd {
7274
/// Manage rate limits on an endpoint.
7375
#[command(subcommand)]
7476
RateLimit(RateLimitCmd),
77+
78+
/// Bulk operations across many endpoints.
79+
#[command(subcommand)]
80+
Bulk(BulkCmd),
7581
}
7682

7783
#[derive(Debug, ClapArgs)]
@@ -190,6 +196,7 @@ pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
190196
EndpointCmd::Tag(c) => tag::run(c, ctx).await,
191197
EndpointCmd::Security(c) => security::run(c, ctx).await,
192198
EndpointCmd::RateLimit(c) => ratelimit::run(c, ctx).await,
199+
EndpointCmd::Bulk(c) => bulk::run(c, ctx).await,
193200
}
194201
}
195202

src/commands/endpoint/tag.rs

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,44 @@
1-
//! `qn endpoint tag {add,remove}` — per-endpoint tag management.
1+
//! `qn endpoint tag ` — endpoint tag management.
22
//!
3-
//! For account-wide tag list/rename/delete, see `qn tag`.
3+
//! Covers both account-wide tag CRUD (`list`, `rename`, `delete`) and
4+
//! per-endpoint tag operations (`add`, `remove`). Tags only exist to label
5+
//! endpoints, which is why the account-wide CRUD lives here too.
46
57
use clap::Subcommand;
6-
use quicknode_sdk::admin::CreateTagRequest;
8+
use comfy_table::Cell;
9+
use quicknode_sdk::admin::{CreateTagRequest, RenameTagRequest};
10+
use serde::Serialize;
711

12+
use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity};
813
use crate::context::Ctx;
914
use crate::errors::CliError;
15+
use crate::output::{new_table, set_header_bold, write_table, Render};
1016

1117
#[derive(Debug, Subcommand)]
1218
pub enum TagCmd {
19+
/// List every tag on the account with usage counts.
20+
#[command(visible_alias = "ls")]
21+
List,
22+
/// Rename a tag.
23+
Rename {
24+
/// Tag id (numeric).
25+
tag_id: i32,
26+
/// New label.
27+
label: String,
28+
},
29+
/// Delete a tag. The tag must not be applied to any endpoint.
30+
Delete {
31+
/// Tag id (numeric).
32+
tag_id: i32,
33+
},
1334
/// Tag an endpoint. Creates the tag on the account if missing.
1435
Add {
1536
/// Endpoint id.
1637
id: String,
1738
/// Tag label.
1839
label: String,
1940
},
20-
/// Remove a tag from an endpoint. `tag_id` is the numeric tag id from `qn tag list`.
41+
/// Remove a tag from an endpoint. `tag_id` is the numeric tag id from `qn endpoint tag list`.
2142
Remove {
2243
/// Endpoint id.
2344
id: String,
@@ -28,17 +49,86 @@ pub enum TagCmd {
2849

2950
pub async fn run(cmd: TagCmd, ctx: Ctx) -> Result<(), CliError> {
3051
match cmd {
31-
TagCmd::Add { id, label } => {
32-
let req = CreateTagRequest {
33-
label: Some(label.clone()),
34-
};
35-
ctx.sdk.admin.create_tag(&id, &req).await?;
36-
ctx.out.note(&format!("✓ Tagged {id} with {label:?}"));
37-
}
38-
TagCmd::Remove { id, tag_id } => {
39-
ctx.sdk.admin.delete_tag(&id, &tag_id).await?;
40-
ctx.out.note(&format!("✓ Removed tag {tag_id} from {id}"));
41-
}
52+
TagCmd::List => list(ctx).await,
53+
TagCmd::Rename { tag_id, label } => rename(tag_id, label, ctx).await,
54+
TagCmd::Delete { tag_id } => delete(tag_id, ctx).await,
55+
TagCmd::Add { id, label } => add(id, label, ctx).await,
56+
TagCmd::Remove { id, tag_id } => remove(id, tag_id, ctx).await,
57+
}
58+
}
59+
60+
async fn list(ctx: Ctx) -> Result<(), CliError> {
61+
let resp = ctx.sdk.admin.list_tags().await?;
62+
crate::output::emit(&ctx.out, &TagsView(resp))
63+
}
64+
65+
async fn rename(tag_id: i32, label: String, ctx: Ctx) -> Result<(), CliError> {
66+
let req = RenameTagRequest {
67+
label: label.clone(),
68+
};
69+
ctx.sdk.admin.rename_tag(tag_id, &req).await?;
70+
ctx.out.note(&format!("✓ Renamed tag {tag_id} → {label:?}"));
71+
Ok(())
72+
}
73+
74+
async fn delete(tag_id: i32, ctx: Ctx) -> Result<(), CliError> {
75+
let cfg = ConfirmCfg::new(
76+
ctx.global.yes_count,
77+
ctx.global.no_input,
78+
ctx.out.stdout_is_tty,
79+
);
80+
let proceed = match decide_without_prompt(Severity::Mild, cfg)? {
81+
true => true,
82+
false => prompt_yes_no(&format!("Delete tag {tag_id}?"))?,
83+
};
84+
if !proceed {
85+
return Err(CliError::Cancelled);
4286
}
87+
ctx.sdk.admin.delete_account_tag(tag_id).await?;
88+
ctx.out.note(&format!("✓ Deleted tag {tag_id}"));
89+
Ok(())
90+
}
91+
92+
async fn add(id: String, label: String, ctx: Ctx) -> Result<(), CliError> {
93+
let req = CreateTagRequest {
94+
label: Some(label.clone()),
95+
};
96+
ctx.sdk.admin.create_tag(&id, &req).await?;
97+
ctx.out.note(&format!("✓ Tagged {id} with {label:?}"));
98+
Ok(())
99+
}
100+
101+
async fn remove(id: String, tag_id: String, ctx: Ctx) -> Result<(), CliError> {
102+
ctx.sdk.admin.delete_tag(&id, &tag_id).await?;
103+
ctx.out.note(&format!("✓ Removed tag {tag_id} from {id}"));
43104
Ok(())
44105
}
106+
107+
#[derive(Serialize)]
108+
struct TagsView(quicknode_sdk::admin::ListTagsResponse);
109+
110+
impl Render for TagsView {
111+
fn render_table(
112+
&self,
113+
w: &mut dyn std::io::Write,
114+
ctx: &crate::output::OutputCtx,
115+
) -> std::io::Result<()> {
116+
let data = match &self.0.data {
117+
Some(d) => d,
118+
None => {
119+
writeln!(w, "(no tag data)")?;
120+
return Ok(());
121+
}
122+
};
123+
let mut t = new_table(ctx);
124+
set_header_bold(&mut t, ctx, vec!["ID", "LABEL", "USAGE"]);
125+
for tg in &data.tags {
126+
t.add_row(vec![
127+
Cell::new(tg.id),
128+
Cell::new(&tg.label),
129+
Cell::new(tg.usage_count),
130+
]);
131+
}
132+
write_table(w, &t)
133+
}
134+
}

src/commands/mod.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@
55
66
pub mod auth;
77
pub mod billing;
8-
pub mod bulk;
98
pub mod chain;
109
pub mod endpoint;
1110
pub mod kv;
1211
pub mod metrics;
1312
pub mod stream;
14-
pub mod tag;
1513
pub mod team;
1614
pub mod usage;
1715
pub mod webhook;

0 commit comments

Comments
 (0)