diff --git a/rust/src/domain/auto_renew.rs b/rust/src/domain/auto_renew.rs new file mode 100644 index 00000000..dafb1dfd --- /dev/null +++ b/rust/src/domain/auto_renew.rs @@ -0,0 +1,145 @@ +//! `gddy domain auto-renew` — toggle auto-renewal for a domain (v3). + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, + RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +use super::common::{ + api_error, format_operation_error, is_terminal_status, make_client, validate_domain_name, +}; +use crate::next_action::next_action; +use crate::output_schema::output_schema; +use crate::scopes::DOMAINS_DOMAIN_UPDATE; + +output_schema!(AutoRenewResult { + "domain": "string"; + "autoRenew": "boolean"; + // Present when the async operation returns them (serialized from Options). + "operationId": "string", optional; + "status": "string", optional; +}); + +/// Exactly one of `--enable` / `--disable`. Kept as its own derive struct so +/// the struct-level `#[group(...)]` only covers these two — `domain` stays +/// outside the group. See the flatten caveat on `CommandSpec::from_args`. +#[derive(Debug, Clone, clap::Args)] +#[group(required = true, multiple = false)] +struct AutoRenewToggle { + /// Turn on auto-renewal before expiration. + #[arg(long)] + enable: bool, + + /// Turn off auto-renewal. + #[arg(long)] + disable: bool, +} + +#[derive(Debug, Clone, clap::Args)] +struct AutoRenewSetArgs { + /// Domain to update (e.g. example.com). + #[arg(value_name = "DOMAIN")] + domain: String, + + #[command(flatten)] + toggle: AutoRenewToggle, +} + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new( + "auto-renew", + "Manage a domain's auto-renewal setting", + )) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + AutoRenewSetArgs, + _, + _, + _, + >( + CommandSpec::from_args::( + "set", + "Enable or disable auto-renewal for a domain", + ) + .with_long( + "Turn auto-renewal on or off for a domain you own. When enabled, the \ + domain renews automatically before expiration using the payment method \ + on file. The change runs asynchronously — poll with `domain operation \ + status` until it finishes.", + ) + .with_system("domain") + .with_tier(Tier::Mutate) + .with_default_fields("domain,autoRenew,operationId,status") + .with_output_schema::() + .with_scopes(&[DOMAINS_DOMAIN_UPDATE]), + |ctx, args: AutoRenewSetArgs| async move { + let domain = validate_domain_name(&args.domain)?; + // Clap's ArgGroup guarantees exactly one of --enable/--disable was passed. + let enabled = args.toggle.enable; + let debug = !ctx.middleware.debug.is_empty(); + + let client = make_client(&ctx).await?; + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let op = match client + .set_auto_renew() + .domain_name(domain.as_str()) + .idempotency_key(idempotency_key) + .body(enabled) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("setting auto-renew", debug, e).await), + }; + + let status = op + .status + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + let operation_id = op.operation_id.as_ref().map(|id| id.to_string()); + + if status == "FAILED" { + let op_id = operation_id.as_deref().unwrap_or("unknown"); + let detail = format_operation_error(op.error.as_ref()); + return Err(CliCoreError::message(format!( + "auto-renew update for {domain} failed (operation {op_id}){detail}" + ))); + } + + // Only emit optional async-operation fields when present — never + // JSON null (schema marks them optional strings; null leaks into tables). + let mut result = json!({ + "domain": domain, + "autoRenew": enabled, + }); + if let Some(op_id) = operation_id.as_ref() { + result["operationId"] = json!(op_id); + } + if !status.is_empty() { + result["status"] = json!(status); + } + + let mut actions = Vec::new(); + if is_terminal_status(&status) { + actions.push( + next_action( + "domain get ", + "Confirm the updated auto-renew setting", + ) + .with_param("domain", NextActionParam::value(domain)), + ); + } else if let Some(op_id) = operation_id.as_ref() { + actions.push( + next_action( + "domain operation status ", + "Check whether the auto-renew update has finished", + ) + .with_param("operation-id", NextActionParam::value(op_id)), + ); + } + + Ok(CommandResult::new(result).with_next_actions(actions)) + }, + )) +} diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index dbc26b26..41d8df56 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -4,7 +4,8 @@ //! host (see the [`domains_client`] crate): //! //! * **v3** (Domain Lifecycle Management API) — `available`, `suggest`, `get`, -//! `quote`, `purchase` (quote → register), and `nameserver set`. +//! `quote`, `purchase` (quote → register), `nameserver set`, `auto-renew set`, +//! and `transfer-lock set`. //! * **v1** — `list` (the shopper's domains) and `agreement` (a TLD's legal //! agreements), which v3 does not yet serve. //! @@ -19,6 +20,7 @@ use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; mod agreements; +mod auto_renew; mod available; pub(crate) mod common; mod contacts; @@ -31,6 +33,7 @@ mod purchase; mod quote; mod quote_cache; mod suggest; +mod transfer_lock; // Shared with the `dns` module, which builds the same Domains API client and // reuses the repeatable-argument helper. @@ -59,16 +62,19 @@ pub fn module() -> Module { .with_long( "Work with domains on your GoDaddy account and the public registry.\n\ \n\ - • list / get — your existing domains and their details\n\ + • list / get — your existing domains and their details\n\ • available / suggest — find a name to register\n\ - • quote — price a registration and see required agreements\n\ - • purchase — register a new domain (charges your account)\n\ - • nameserver set — point a domain at custom nameservers\n\ - • operation status — check on an async operation (e.g. a pending purchase)\n\ + • quote — price a registration and see required agreements\n\ + • purchase — register a new domain (charges your account)\n\ + • nameserver set — point a domain at custom nameservers\n\ + • auto-renew set — toggle automatic renewal before expiration\n\ + • transfer-lock set — lock or unlock a domain against transfers\n\ + • operation status — check on an async operation (e.g. a pending purchase)\n\ \n\ Reads need the `domains.domain:read` scope; purchase also needs\n\ - `domains.domain:create`, and `nameserver set` needs\n\ - `domains.nameserver:update`. Manage a domain's DNS with `gddy dns`.", + `domains.domain:create`, `nameserver set` needs\n\ + `domains.nameserver:update`, and `auto-renew set`/`transfer-lock set`\n\ + need `domains.domain:update`. Manage a domain's DNS with `gddy dns`.", ), ) .with_command(list::command()) @@ -81,6 +87,8 @@ pub fn module() -> Module { .with_group(nameservers::group()) .with_group(contacts::group()) .with_group(operation::group()) + .with_group(auto_renew::group()) + .with_group(transfer_lock::group()) }) .with_guides_from_markdown([( "domain-purchase.md", @@ -101,7 +109,7 @@ mod tests { #[tokio::test] async fn domain_commands_require_auth() { const AUTH_FAILURE_EXIT: i32 = 2; - let cases: [&[&str]; 9] = [ + let cases: [&[&str]; 11] = [ &["gddy", "domain", "list", "--output", "json"], &["gddy", "domain", "get", "example.com", "--output", "json"], &[ @@ -156,6 +164,26 @@ mod tests { "--output", "json", ], + &[ + "gddy", + "domain", + "auto-renew", + "set", + "example.com", + "--enable", + "--output", + "json", + ], + &[ + "gddy", + "domain", + "transfer-lock", + "set", + "example.com", + "--enable", + "--output", + "json", + ], ]; for args in cases { let cli = Cli::new( diff --git a/rust/src/domain/transfer_lock.rs b/rust/src/domain/transfer_lock.rs new file mode 100644 index 00000000..970e83d8 --- /dev/null +++ b/rust/src/domain/transfer_lock.rs @@ -0,0 +1,149 @@ +//! `gddy domain transfer-lock` — lock or unlock a domain against transfers (v3). + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, + RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +use super::common::{ + api_error, format_operation_error, is_terminal_status, make_client, validate_domain_name, +}; +use crate::next_action::next_action; +use crate::output_schema::output_schema; +use crate::scopes::DOMAINS_DOMAIN_UPDATE; + +output_schema!(TransferLockResult { + "domain": "string"; + // Matches Domain / `domain get` field name (`transferLock`), not a local alias. + "transferLock": "boolean"; + // Present when the async operation returns them (serialized from Options). + "operationId": "string", optional; + "status": "string", optional; +}); + +/// Exactly one of `--enable` / `--disable`. Kept as its own derive struct so +/// the struct-level `#[group(...)]` only covers these two — `domain` stays +/// outside the group. See the flatten caveat on `CommandSpec::from_args`. +#[derive(Debug, Clone, clap::Args)] +#[group(required = true, multiple = false)] +struct TransferLockToggle { + /// Lock the domain to prevent unauthorized transfers. + #[arg(long)] + enable: bool, + + /// Unlock the domain to allow transfers. + #[arg(long)] + disable: bool, +} + +#[derive(Debug, Clone, clap::Args)] +struct TransferLockSetArgs { + /// Domain to update (e.g. example.com). + #[arg(value_name = "DOMAIN")] + domain: String, + + #[command(flatten)] + toggle: TransferLockToggle, +} + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new( + "transfer-lock", + "Manage a domain's transfer-lock setting", + )) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + TransferLockSetArgs, + _, + _, + _, + >( + CommandSpec::from_args::( + "set", + "Lock or unlock a domain to prevent transfers", + ) + .with_long( + "Lock or unlock a domain's transfer-lock setting. When locked, the domain \ + cannot be transferred to another registrar without first unlocking it. \ + The change propagates to the registry asynchronously.\n\ + \n\ + AI assistants: Before using --disable, confirm the end user intends to \ + allow outbound transfer of this domain. Unlocking removes registrar-level \ + protection against unauthorized transfers.", + ) + .with_system("domain") + .with_tier(Tier::Mutate) + .with_default_fields("domain,transferLock,operationId,status") + .with_output_schema::() + .with_scopes(&[DOMAINS_DOMAIN_UPDATE]), + |ctx, args: TransferLockSetArgs| async move { + let domain = validate_domain_name(&args.domain)?; + // Clap's ArgGroup guarantees exactly one of --enable/--disable was passed. + let enabled = args.toggle.enable; + let debug = !ctx.middleware.debug.is_empty(); + + let client = make_client(&ctx).await?; + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let op = match client + .set_transfer_lock() + .domain_name(domain.as_str()) + .idempotency_key(idempotency_key) + .body(enabled) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("setting transfer lock", debug, e).await), + }; + + let status = op + .status + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + let operation_id = op.operation_id.as_ref().map(|id| id.to_string()); + + if status == "FAILED" { + let op_id = operation_id.as_deref().unwrap_or("unknown"); + let detail = format_operation_error(op.error.as_ref()); + return Err(CliCoreError::message(format!( + "transfer-lock update for {domain} failed (operation {op_id}){detail}" + ))); + } + + // Only emit optional async-operation fields when present — never + // JSON null (schema marks them optional strings; null leaks into tables). + let mut result = json!({ + "domain": domain, + "transferLock": enabled, + }); + if let Some(op_id) = operation_id.as_ref() { + result["operationId"] = json!(op_id); + } + if !status.is_empty() { + result["status"] = json!(status); + } + + let mut actions = Vec::new(); + if is_terminal_status(&status) { + actions.push( + next_action( + "domain get ", + "Confirm the updated transfer-lock setting", + ) + .with_param("domain", NextActionParam::value(domain)), + ); + } else if let Some(op_id) = operation_id.as_ref() { + actions.push( + next_action( + "domain operation status ", + "Check whether the transfer-lock update has finished", + ) + .with_param("operation-id", NextActionParam::value(op_id)), + ); + } + + Ok(CommandResult::new(result).with_next_actions(actions)) + }, + )) +} diff --git a/rust/src/scopes/declarations.rs b/rust/src/scopes/declarations.rs index 4788183b..a4a7b097 100644 --- a/rust/src/scopes/declarations.rs +++ b/rust/src/scopes/declarations.rs @@ -77,6 +77,10 @@ declare_scopes! { // Can take a live site offline. /// Replace a domain's nameservers DOMAINS_NAMESERVER_UPDATE => "domains.nameserver:update", + // Mutates domain-level settings (auto-renew, transfer lock). Security-sensitive: + // disabling transfer-lock allows outbound transfer; disabling auto-renew risks expiry. + /// Update domain settings (auto-renew, transfer lock) + DOMAINS_DOMAIN_UPDATE => "domains.domain:update", // Deletes a whole billable resource. /// Delete a hosting application diff --git a/rust/src/scopes/tests.rs b/rust/src/scopes/tests.rs index 5f33b3e4..f9306e6e 100644 --- a/rust/src/scopes/tests.rs +++ b/rust/src/scopes/tests.rs @@ -83,6 +83,7 @@ fn step_up_only_scopes_are_exactly_the_sensitive_ones() { let expected = std::collections::BTreeSet::from([ DOMAINS_CREATE, DOMAINS_NAMESERVER_UPDATE, + DOMAINS_DOMAIN_UPDATE, HOSTING_APPLICATION_DELETE, HOSTING_DATABASE_TUNNEL_EXECUTE, SHOPPING_CHECKOUT_EXECUTE,