Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions rust/src/domain/auto_renew.rs
Original file line number Diff line number Diff line change
@@ -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::<AutoRenewSetArgs>(
"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)
Comment thread
jpage-godaddy marked this conversation as resolved.
.with_default_fields("domain,autoRenew,operationId,status")
.with_output_schema::<AutoRenewResult>()
.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 <domain>",
"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 <operation-id>",
"Check whether the auto-renew update has finished",
)
.with_param("operation-id", NextActionParam::value(op_id)),
);
}

Ok(CommandResult::new(result).with_next_actions(actions))
},
))
}
46 changes: 37 additions & 9 deletions rust/src/domain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand All @@ -19,6 +20,7 @@
use cli_engine::{GroupSpec, Module, RuntimeGroupSpec};

mod agreements;
mod auto_renew;
mod available;
pub(crate) mod common;
mod contacts;
Expand All @@ -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.
Expand Down Expand Up @@ -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())
Expand All @@ -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",
Expand All @@ -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"],
&[
Expand Down Expand Up @@ -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(
Expand Down
149 changes: 149 additions & 0 deletions rust/src/domain/transfer_lock.rs
Original file line number Diff line number Diff line change
@@ -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::<TransferLockSetArgs>(
"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)
Comment thread
jpage-godaddy marked this conversation as resolved.
.with_default_fields("domain,transferLock,operationId,status")
.with_output_schema::<TransferLockResult>()
.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 <domain>",
"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 <operation-id>",
"Check whether the transfer-lock update has finished",
)
.with_param("operation-id", NextActionParam::value(op_id)),
);
}

Ok(CommandResult::new(result).with_next_actions(actions))
},
))
}
Loading
Loading