diff --git a/.gitignore b/.gitignore index 784d68be..3d4fde79 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ __pycache__ # Claude Code local runtime state (per-machine, not for commit) **/.claude/scheduled_tasks.lock **/.claude/scheduled_tasks.json +.cursor/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 15cca1c1..78f6ff3f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -544,9 +544,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abdd934fca13a77d706d45bae8289d7d35f90ff077c99dd7404bda279a3bd3af" +checksum = "45f7570f3516078ba03dba1e310bf092161bac656bd9bd3bfd6fb0d7b419dfdf" dependencies = [ "async-trait", "base64", @@ -566,6 +566,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "strsim", "termimad", "thiserror", "tokio", @@ -643,6 +644,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -902,6 +915,18 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -1010,6 +1035,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "endi" version = "1.1.1" @@ -1347,12 +1378,15 @@ dependencies = [ "chrono", "clap", "cli-engine", + "console", + "dialoguer", "dirs", "domains-client", "fancy-regex", "flate2", "globset", "httpmock", + "indicatif", "iso_currency", "open", "oxc_allocator", @@ -1782,6 +1816,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "inout" version = "0.1.4" @@ -2703,6 +2750,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "postcard" version = "1.1.3" @@ -3638,6 +3691,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -4341,6 +4400,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index fb1be1e9..b2415ad2 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -51,6 +51,9 @@ url = "2" uuid = { version = "1", features = ["v4"] } zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2"] } iso_currency = "0.5.3" +dialoguer = "0.12.0" +console = "0.16.4" +indicatif = "0.18.6" [dev-dependencies] httpmock = "0.8" diff --git a/rust/src/config/settings_form.rs b/rust/src/config/settings_form.rs index 03a62229..85be6b3d 100644 --- a/rust/src/config/settings_form.rs +++ b/rust/src/config/settings_form.rs @@ -271,10 +271,10 @@ fn validate_field(field: &SettingsFormV1Field, errors: &mut Vec, path: & } match field { SettingsFormV1Field::Select { options, .. } - | SettingsFormV1Field::MultiSelect { options, .. } => { - if options.is_empty() { - errors.push(format!("{path}.options must contain at least one option")); - } + | SettingsFormV1Field::MultiSelect { options, .. } + if options.is_empty() => + { + errors.push(format!("{path}.options must contain at least one option")); } SettingsFormV1Field::ListGroup { item, .. } => { if !is_field_name(&item.id_field) { diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index 630ce45e..7c512637 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -7,7 +7,9 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, format_money, make_client, period_label, validate_domain_name}; +use super::common::{ + api_error, format_money, make_client, period_label, term_for_period, validate_domain_name, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -159,14 +161,29 @@ pub(super) fn command() -> RuntimeCommandSpec { let cmd = CommandResult::new(result); if body.available.unwrap_or(false) { + // If interactive, offer to continue directly into registration. + let price_1yr = term_for_period(&prices, 1) + .and_then(|t| t.price.as_ref()) + .and_then(format_money); + let currency_str = shared_currency(&prices); + if let Some(wizard_result) = + super::register::bridge::offer_registration_from_available( + &ctx, + &resolved_domain, + price_1yr, + currency_str, + ) + .await? + { + return Ok(wizard_result); + } + Ok(cmd.with_next_actions(vec![ next_action("domain quote ", "Price a registration") .with_param("domain", NextActionParam::value(resolved_domain)), ])) } else { Ok(cmd.with_next_actions(vec![ - // `domain suggest` accepts a seed domain, so the domain just - // checked as taken is a valid query to copy/paste directly. next_action("domain suggest ", "Find alternatives") .with_param("query", NextActionParam::value(resolved_domain)), ])) diff --git a/rust/src/domain/guides/domain-register.md b/rust/src/domain/guides/domain-register.md new file mode 100644 index 00000000..415bf9d7 --- /dev/null +++ b/rust/src/domain/guides/domain-register.md @@ -0,0 +1,94 @@ +--- +summary: Interactive domain registration wizard — guided step-by-step domain purchase +--- + +# Interactive domain registration with `gddy domain register` + +The `register` command is an interactive wizard that walks you through +the entire domain registration process in a single session: + +``` +gddy domain register +``` + +## How it works + +The wizard guides you through 5 steps: + +1. **Discovery** — Search for a domain or enter one directly. If taken, view + suggestions and pick an alternative. +2. **Options** — Choose registration period (1–10 years), WHOIS privacy, and + auto-renewal. Optionally set custom nameservers. +3. **Contacts** — Use your account default contacts, load saved contacts from + `contacts.toml`, or enter new ones interactively. +4. **Review & Confirm** — See the full order summary (price, renewal, agreements) + and explicitly consent before any charge is made. +5. **Register** — Submit the registration and wait for the registry to confirm. + +You can go back to a previous step at any point. Pressing Ctrl+C at any time +cancels the wizard — **no charges are made until you explicitly confirm in +Step 4**. + +## Non-interactive mode + +For scripts and CI, pass all options as flags: + +``` +gddy domain register example.com \ + --period 2 \ + --privacy true \ + --auto-renew true \ + --agree \ + --confirm +``` + +Required flags in non-interactive mode: +- Domain name (positional argument) +- `--agree` — consent to legal agreements +- `--confirm` — authorize the purchase + +## Contacts + +The wizard checks for saved contacts at `~/.config/gddy/contacts.toml`. +If found, you can reuse them without re-entering details each time. + +To create a starter contacts file: +``` +gddy domain contacts init +``` + +When you enter contacts manually during the wizard, you'll be offered to save +them for future registrations. + +## Payment + +A valid payment method (credit card or Good-as-Gold balance) must be on file. +If the quote fails with a payment error, the wizard will offer to open the +GoDaddy payment methods page in your browser. + +## Entry from other commands + +When running interactively, related commands offer to continue into registration: + +- `gddy domain available example.com` — if available, asks "Would you like to register?" +- `gddy domain suggest "keywords"` — after results, asks "Would you like to register one?" +- `gddy domain quote example.com` — after pricing, asks "Would you like to purchase now?" + +## Examples + +``` +# Full interactive wizard +gddy domain register + +# Start with a specific domain (skips discovery search) +gddy domain register example.com + +# Non-interactive for scripts +gddy domain register example.com --period 1 --agree --confirm + +# With custom nameservers +gddy domain register example.com \ + --nameserver ns1.example.net \ + --nameserver ns2.example.net \ + --agree --confirm +``` diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index fdf21621..7ea28fd7 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -28,6 +28,7 @@ mod nameservers; mod operation; mod purchase; mod quote; +mod register; mod suggest; // Shared with the `dns` module, which builds the same Domains API client and @@ -46,13 +47,13 @@ pub fn module() -> Module { \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\ + • register — interactive wizard: discover, configure, and buy\n\ + • quote / purchase — scripted two-step registration (quote then buy)\n\ • nameservers set — point a domain at custom nameservers\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 `nameservers set` needs\n\ + Reads need the `domains.domain:read` scope; purchase/register also\n\ + need `domains.domain:create`, and `nameservers set` needs\n\ `domains.nameserver:update`. Manage a domain's DNS with `gddy dns`.", ), ) @@ -63,14 +64,21 @@ pub fn module() -> Module { .with_command(agreements::command()) .with_command(quote::command()) .with_command(purchase::command()) + .with_command(register::command()) .with_group(nameservers::group()) .with_group(contacts::group()) .with_group(operation::group()) }) - .with_guides_from_markdown([( - "domain-purchase.md", - include_bytes!("guides/domain-purchase.md").as_slice(), - )]) + .with_guides_from_markdown([ + ( + "domain-purchase.md", + include_bytes!("guides/domain-purchase.md").as_slice(), + ), + ( + "domain-register.md", + include_bytes!("guides/domain-register.md").as_slice(), + ), + ]) } #[cfg(test)] diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 6d8a007e..34a6590e 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -397,6 +397,29 @@ pub(super) fn command() -> RuntimeCommandSpec { // find it. Warn so the user knows to re-quote on this host. tracing::warn!(error = %e, "could not cache the quote for purchase"); } + // If interactive, offer to purchase directly. + let quote_price = view + .get("price") + .and_then(|v| v.as_str()) + .map(str::to_owned); + let quote_currency = view + .get("currency") + .and_then(|v| v.as_str()) + .map(str::to_owned); + if let Some(wizard_result) = + super::register::bridge::offer_registration_from_quote( + &ctx, + &domain, + &token, + quote_price, + quote_currency, + period, + ) + .await? + { + return Ok(wizard_result); + } + next_actions.push( next_action( "domain purchase --quote-token --agree --confirm", diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs new file mode 100644 index 00000000..07310fe6 --- /dev/null +++ b/rust/src/domain/register/bridge.rs @@ -0,0 +1,152 @@ +//! Bridge functions allowing other domain commands (suggest, available, quote) +//! to hand off to the registration wizard when running interactively. + +use cli_engine::{CliCoreError, CommandResult, Result}; +use dialoguer::Confirm; + +use super::WizardExit; +use super::wizard::WizardState; + +/// After `domain available` finds a domain is available, offer to continue +/// with registration. Returns `None` if the user declines or the wizard is +/// cancelled. Re-asks if the user navigates back from the wizard. +pub(crate) async fn offer_registration_from_available( + ctx: &cli_engine::CommandContext, + domain: &str, + price: Option, + currency: Option, +) -> Result> { + if !ctx.is_interactive() { + return Ok(None); + } + + loop { + let proceed = Confirm::new() + .with_prompt(format!("Would you like to register {domain}?")) + .default(false) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if !proceed { + return Ok(None); + } + + let mut state = WizardState::new().with_domain(Some(domain.to_owned())); + state.available = true; + state.price = price.clone(); + state.currency = currency.clone(); + + match super::launch_wizard(ctx, state, 1).await? { + WizardExit::Completed(result) => { + return Ok(Some(super::present_for_host_command(ctx, result))); + } + WizardExit::BackedOut => continue, + WizardExit::Cancelled => return Ok(None), + } + } +} + +/// After `domain suggest` displays results, offer to pick one and register. +/// Returns `None` if the user declines or the wizard is cancelled. Re-shows +/// the selection if the user navigates back from the wizard. +pub(crate) async fn offer_registration_from_suggest( + ctx: &cli_engine::CommandContext, + suggestions: &[String], +) -> Result> { + if !ctx.is_interactive() || suggestions.is_empty() { + return Ok(None); + } + + // Show suggestions inline so the user sees what's available before choosing. + eprintln!("\n Here are some available domains based on your input:\n"); + for (i, name) in suggestions.iter().enumerate() { + eprintln!(" {}. {}", i + 1, name); + } + eprintln!(); + + let mut items: Vec = suggestions.to_vec(); + items.push("(enter a different domain)".to_owned()); + items.push("(skip — just show results)".to_owned()); + + loop { + let selection = dialoguer::Select::new() + .with_prompt("Would you like to register one of these domains? Select one to proceed") + .items(&items) + .default(items.len() - 1) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + // Last option = skip, return None to let normal output render. + if selection == items.len() - 1 { + return Ok(None); + } + + // Second-to-last = enter a different domain. + let domain = if selection == items.len() - 2 { + None + } else { + Some(items[selection].clone()) + }; + + let mut state = WizardState::new().with_domain(domain.clone()); + let exit = if domain.is_some() { + state.available = true; + super::launch_wizard(ctx, state, 1).await? + } else { + super::launch_wizard(ctx, state, 0).await? + }; + + match exit { + WizardExit::Completed(result) => { + return Ok(Some(super::present_for_host_command(ctx, result))); + } + WizardExit::BackedOut => continue, + WizardExit::Cancelled => return Ok(None), + } + } +} + +/// After `domain quote` prices a domain, offer to purchase it directly. +/// Returns `None` if the user declines or the wizard is cancelled. Re-asks if +/// the user navigates back from the wizard. +pub(crate) async fn offer_registration_from_quote( + ctx: &cli_engine::CommandContext, + domain: &str, + quote_token: &str, + price: Option, + currency: Option, + period: u64, +) -> Result> { + if !ctx.is_interactive() { + return Ok(None); + } + + loop { + let proceed = Confirm::new() + .with_prompt(format!("Would you like to purchase {domain} now?")) + .default(false) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if !proceed { + return Ok(None); + } + + let mut state = WizardState::new() + .with_domain(Some(domain.to_owned())) + .with_period(period); + state.available = true; + state.quote_token = Some(quote_token.to_owned()); + state.price = price.clone(); + state.currency = currency.clone(); + + // Start at step 3 (Review & Confirm) since quote is already done. + match super::launch_wizard(ctx, state, 3).await? { + WizardExit::Completed(result) => { + return Ok(Some(super::present_for_host_command(ctx, result))); + } + WizardExit::BackedOut => continue, + WizardExit::Cancelled => return Ok(None), + } + } +} diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs new file mode 100644 index 00000000..534fbd75 --- /dev/null +++ b/rust/src/domain/register/mod.rs @@ -0,0 +1,516 @@ +//! `gddy domain register` — interactive guided domain registration wizard. +//! +//! Walks the user through discovery → configure → confirm → buy in a single +//! session. In non-interactive mode, all options must be passed as flags; +//! the command validates them and executes directly without prompts. + +use cli_engine::{ + CliCoreError, CommandContext, CommandResult, CommandSpec, Envelope, NextActionParam, Result, + RuntimeCommandSpec, TableColumn, Tier, render_human_with_view, +}; +use serde_json::json; + +use crate::domain::common::{is_terminal_status, validate_domain_name}; +use crate::next_action::{next_action, required_value}; +use crate::output_schema::output_schema; +use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; + +// Wizard steps write interactive UI to stderr via eprintln and dialoguer/console. +// This is intentional user-facing output, not diagnostic logging. +#[allow(clippy::print_stderr)] +pub(crate) mod bridge; +#[allow(clippy::print_stderr)] +pub(crate) mod steps; +#[allow(clippy::print_stderr)] +pub(crate) mod wizard; + +use wizard::{StepContext, WizardState}; + +output_schema!(DomainRegisterResult { + "domain": "string"; + "status": "string"; + "operationId": "string", optional; + "price": "string", optional; + "currency": "string", optional; +}); + +fn view_columns() -> Vec { + vec![ + TableColumn::new("domain", "Domain"), + TableColumn::new("status", "Status"), + TableColumn::new("operationId", "Operation ID").no_truncate(true), + TableColumn::new("price", "Price"), + TableColumn::new("currency", "Currency"), + ] +} + +#[derive(Debug, Clone, clap::Args)] +struct RegisterArgs { + /// Domain name to register (omit for interactive discovery). + #[arg(value_name = "DOMAIN")] + domain: Option, + + /// Registration period in years (default: 1). + #[arg(long, default_value = "1", value_name = "YEARS")] + period: u64, + + /// Enable WHOIS privacy protection. + #[arg(long, default_value = "true", action = clap::ArgAction::Set)] + privacy: bool, + + /// Enable automatic renewal. + #[arg(long = "auto-renew", default_value = "true", action = clap::ArgAction::Set)] + auto_renew: bool, + + /// Custom nameserver (repeatable; omit for GoDaddy defaults). + #[arg(long = "nameserver", value_name = "HOST")] + nameservers: Vec, + + /// Consent to legal agreements (required in non-interactive mode). + #[arg(long)] + agree: bool, + + /// Confirm the purchase (required in non-interactive mode). + #[arg(long)] + confirm: bool, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "register", + "Register a new domain (interactive wizard or direct)", + ) + .with_long( + "Register a new domain interactively or with flags.\n\ + \n\ + In interactive mode (default when running in a terminal), the wizard\n\ + walks you through: domain discovery → registration options → quote\n\ + review → purchase execution.\n\ + \n\ + In non-interactive mode (piped input, CI, or --non-interactive), pass\n\ + the domain name and all options as flags:\n\ + \n \ + gddy domain register example.com --period 1 --agree --confirm\n\ + \n\ + Registration charges your GoDaddy account and cannot be undone.\n\ + A usable payment method must be on file.", + ) + .with_system("domain") + .with_tier(Tier::Destructive) + .with_default_fields("domain,status,operationId,price,currency") + .with_output_schema::() + .with_view(view_columns()) + .with_scopes(&[DOMAINS_READ, DOMAINS_CREATE]), + |ctx, args: RegisterArgs| async move { + let is_interactive = ctx.is_interactive(); + + if is_interactive { + run_interactive(ctx, args).await + } else { + run_non_interactive(ctx, args).await + } + }, + ) +} + +async fn run_interactive(ctx: CommandContext, args: RegisterArgs) -> Result { + let cred = ctx.credential().await?; + let env = ctx.middleware.env.clone(); + let debug = !ctx.middleware.debug.is_empty(); + + // Pre-populate state from any flags the user already provided. + let domain = args.domain.map(|d| validate_domain_name(&d)).transpose()?; + + let state = WizardState::new() + .with_domain(domain) + .with_period(args.period) + .with_privacy(args.privacy) + .with_auto_renew(args.auto_renew) + .with_nameservers(args.nameservers); + + let step_ctx = StepContext { + credential: cred, + env, + debug, + }; + + let final_state = wizard::run_wizard(state, step_ctx, 0).await?; + + if final_state.cancelled { + return Ok(CommandResult::new(json!({"status": "cancelled"}))); + } + build_result(&final_state) +} + +async fn run_non_interactive(ctx: CommandContext, args: RegisterArgs) -> Result { + let domain = args.domain.ok_or_else(|| { + CliCoreError::message( + "domain name is required in non-interactive mode; pass it as a positional argument\n\ + \n Example: gddy domain register example.com --period 1 --agree --confirm", + ) + })?; + let domain = validate_domain_name(&domain)?; + + if !args.agree { + return Err(CliCoreError::message( + "--agree is required in non-interactive mode to consent to legal agreements", + )); + } + if !args.confirm { + return Err(CliCoreError::message( + "--confirm is required in non-interactive mode to authorize the purchase charge", + )); + } + + let cred = ctx.credential().await?; + let env = ctx.middleware.env.clone(); + let debug = !ctx.middleware.debug.is_empty(); + + let mut state = WizardState::new() + .with_domain(Some(domain)) + .with_period(args.period) + .with_privacy(args.privacy) + .with_auto_renew(args.auto_renew) + .with_nameservers(args.nameservers); + state.available = true; + + let step_ctx = StepContext { + credential: cred, + env, + debug, + }; + + // Non-interactive: skip all interactive prompts. --agree and --confirm + // were validated above, so we go straight to quoting and executing. + steps::review::run_non_interactive(&mut state, &step_ctx).await?; + steps::execute::run(&mut state, &step_ctx).await?; + + build_result(&state) +} + +/// Wizard exit disposition, distinguishing user-initiated back-navigation from +/// explicit cancellation. +pub(crate) enum WizardExit { + /// Wizard completed — here's the result. + Completed(CommandResult), + /// User navigated back past the entry step (caller should re-show its UI). + BackedOut, + /// User explicitly cancelled (Cancel option or Ctrl+C). The wizard already + /// printed a user-facing message; callers should exit without rendering + /// additional output. + Cancelled, +} + +/// Launch the wizard from an external command (e.g. `domain available --interactive`). +/// `start_at` determines which step to begin from (0=discovery, 1=options, etc.). +pub(crate) async fn launch_wizard( + ctx: &CommandContext, + state: WizardState, + start_at: usize, +) -> Result { + let cred = ctx.credential().await?; + let env = ctx.middleware.env.clone(); + let debug = !ctx.middleware.debug.is_empty(); + + let step_ctx = StepContext { + credential: cred, + env, + debug, + }; + + let final_state = wizard::run_wizard(state, step_ctx, start_at).await?; + if final_state.backed_out { + return Ok(WizardExit::BackedOut); + } + if final_state.cancelled { + return Ok(WizardExit::Cancelled); + } + build_result(&final_state).map(WizardExit::Completed) +} + +fn build_result(state: &WizardState) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain in final state"))?; + let status = state.status.as_deref().unwrap_or("UNKNOWN"); + + let mut result = json!({ + "domain": domain, + "status": status, + }); + if let Some(op) = &state.operation_id { + result["operationId"] = json!(op); + } + if let Some(p) = &state.price { + result["price"] = json!(p); + } + if let Some(c) = &state.currency { + result["currency"] = json!(c); + } + + let mut actions = vec![ + next_action("domain get ", next_action_description(status)) + .with_param("domain", required_value(domain.clone())), + next_action( + "dns set --type A --name @ --data ", + "Point the apex at an IPv4 address", + ) + .with_param("domain", required_value(domain.clone())) + .with_param("ip", NextActionParam::required()), + ]; + // Mirror `domain purchase`: when polling gave up before a terminal state, + // surface the operation-status follow-up with the concrete id prefilled. + if !is_terminal_status(status) + && let Some(op) = &state.operation_id + { + actions.push( + next_action( + "domain operation status ", + "Check whether registration has finished since polling gave up", + ) + .with_param("operation-id", required_value(op.clone())), + ); + } + + Ok(CommandResult::new(result).with_next_actions(actions)) +} + +fn next_action_description(status: &str) -> &'static str { + if status == "COMPLETED" { + "See the registered domain's details" + } else { + "Check whether the domain has finished registering" + } +} + +/// Adapt a wizard `CommandResult` for return through a different leaf command +/// (`domain suggest` / `available` / `quote`). +/// +/// Those hosts register their own human views (e.g. suggest's `1yr Price` +/// columns). Rendering a register-shaped payload through them produces empty +/// mismatched fields. For human output we render with the register view +/// ourselves, then return an empty string so the host view path is bypassed +/// (scalar data skips column rendering). JSON/TOON keep the structured result. +pub(crate) fn present_for_host_command( + ctx: &CommandContext, + result: CommandResult, +) -> CommandResult { + if ctx.middleware.output_format != "human" { + return result; + } + + let envelope = + Envelope::success(result.data, "domain").with_next_actions(result.metadata.next_actions); + let rendered = render_human_with_view(&envelope, Some(&view_columns()), ""); + // Write the correctly shaped register summary now; middleware will then + // render the empty-string placeholder below (a blank line), not the host + // command's mismatched table. Use Write rather than print! so the + // print_stdout lint (denied as warnings) does not fire on intentional + // human-output writes. + let _ = std::io::Write::write_all(&mut std::io::stdout(), rendered.as_bytes()); + CommandResult::new(json!("")) +} + +#[cfg(test)] +mod tests { + use super::*; + use wizard::WizardState; + + #[test] + fn build_result_requires_domain_in_state() { + let state = WizardState::new(); + let err = build_result(&state).expect_err("should fail without domain"); + assert!( + err.to_string().contains("no domain"), + "expected domain error, got: {err}" + ); + } + + #[test] + fn build_result_produces_valid_json_with_minimal_state() { + let mut state = WizardState::new(); + state.domain = Some("example.com".to_string()); + state.status = Some("COMPLETED".to_string()); + + let result = build_result(&state).expect("should succeed"); + assert_eq!(result.data["domain"], "example.com"); + assert_eq!(result.data["status"], "COMPLETED"); + assert!(result.data.get("operationId").is_none()); + } + + #[test] + fn build_result_includes_optional_fields_when_present() { + let mut state = WizardState::new(); + state.domain = Some("test.io".to_string()); + state.status = Some("COMPLETED".to_string()); + state.operation_id = Some("op-123".to_string()); + state.price = Some("12.99".to_string()); + state.currency = Some("USD".to_string()); + + let result = build_result(&state).expect("should succeed"); + assert_eq!(result.data["operationId"], "op-123"); + assert_eq!(result.data["price"], "12.99"); + assert_eq!(result.data["currency"], "USD"); + } + + #[test] + fn build_result_prefills_domain_in_next_actions() { + let mut state = WizardState::new(); + state.domain = Some("example.com".to_string()); + state.status = Some("COMPLETED".to_string()); + + let result = build_result(&state).expect("should succeed"); + assert!( + !result.metadata.next_actions.is_empty(), + "expected next actions" + ); + let get = &result.metadata.next_actions[0]; + let domain = get + .params + .get("domain") + .and_then(|p| p.value.as_deref()) + .expect("domain param"); + assert_eq!(domain, "example.com"); + // Substitution of `` → `example.com` happens at render time + // via the envelope's next_actions footer; the stored template keeps + // the placeholder, with the concrete value in params. + assert!(get.command.contains(""), "{}", get.command); + assert!(get.params.get("domain").is_some_and(|p| p.required)); + } + + #[test] + fn build_result_adds_operation_status_when_still_pending() { + let mut state = WizardState::new(); + state.domain = Some("example.com".to_string()); + state.status = Some("EXECUTING".to_string()); + state.operation_id = Some("op-abc".to_string()); + + let result = build_result(&state).expect("should succeed"); + let status_action = result + .metadata + .next_actions + .iter() + .find(|a| a.command.contains("operation status")) + .expect("pending registration should suggest operation status"); + assert_eq!( + status_action + .params + .get("operation-id") + .and_then(|p| p.value.as_deref()), + Some("op-abc") + ); + } + + #[test] + fn register_view_renders_register_shaped_payload() { + let payload = json!({ + "domain": "example.com", + "status": "COMPLETED", + "operationId": "op-1", + "price": "12.99", + "currency": "USD", + }); + let envelope = Envelope::success(payload, "domain"); + let rendered = render_human_with_view(&envelope, Some(&view_columns()), ""); + assert!(rendered.contains("Domain:"), "{rendered}"); + assert!(rendered.contains("example.com"), "{rendered}"); + assert!(rendered.contains("Status:"), "{rendered}"); + assert!(rendered.contains("COMPLETED"), "{rendered}"); + assert!(rendered.contains("12.99"), "{rendered}"); + assert!( + !rendered.contains("1yr Price"), + "must not use suggest's view labels: {rendered}" + ); + } + + #[test] + fn register_args_defaults_are_user_friendly() { + // Verify clap defaults match WizardState defaults. + let cmd = clap::Command::new("test"); + let cmd = ::augment_args(cmd); + + // period default is "1" + let period_arg = cmd.get_arguments().find(|a| a.get_id() == "period"); + assert!(period_arg.is_some()); + + // privacy default is "true" + let privacy_arg = cmd.get_arguments().find(|a| a.get_id() == "privacy"); + assert!(privacy_arg.is_some()); + } + + #[test] + fn non_interactive_requires_domain_arg() { + let args = RegisterArgs { + domain: None, + period: 1, + privacy: true, + auto_renew: true, + nameservers: vec![], + agree: true, + confirm: true, + }; + // Simulate the check from run_non_interactive. + let err = args.domain.ok_or_else(|| { + CliCoreError::message("domain name is required in non-interactive mode") + }); + assert!(err.is_err()); + assert!( + err.expect_err("should be missing domain") + .to_string() + .contains("domain name is required") + ); + } + + #[test] + fn non_interactive_requires_agree_flag() { + let args = RegisterArgs { + domain: Some("example.com".to_string()), + period: 1, + privacy: true, + auto_renew: true, + nameservers: vec![], + agree: false, + confirm: true, + }; + assert!(!args.agree, "--agree should be false"); + } + + #[test] + fn non_interactive_requires_confirm_flag() { + let args = RegisterArgs { + domain: Some("example.com".to_string()), + period: 1, + privacy: true, + auto_renew: true, + nameservers: vec![], + agree: true, + confirm: false, + }; + assert!(!args.confirm, "--confirm should be false"); + } + + #[test] + fn wizard_state_from_args_maps_correctly() { + let args = RegisterArgs { + domain: Some("test.io".to_string()), + period: 3, + privacy: false, + auto_renew: false, + nameservers: vec!["ns1.test.io".to_string(), "ns2.test.io".to_string()], + agree: true, + confirm: true, + }; + let state = WizardState::new() + .with_period(args.period) + .with_privacy(args.privacy) + .with_auto_renew(args.auto_renew) + .with_nameservers(args.nameservers.clone()); + + assert_eq!(state.period, 3); + assert!(!state.privacy); + assert!(!state.auto_renew); + assert_eq!(state.nameservers.len(), 2); + } +} diff --git a/rust/src/domain/register/steps/contacts.rs b/rust/src/domain/register/steps/contacts.rs new file mode 100644 index 00000000..02b988b9 --- /dev/null +++ b/rust/src/domain/register/steps/contacts.rs @@ -0,0 +1,428 @@ +//! Step 3: Contacts — offer to reuse saved contacts, enter new ones, or use +//! account defaults. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use dialoguer::{Confirm, Input, Select}; + +use crate::contacts::{self, Contact, ContactsFile, Role}; + +use super::super::wizard::{ContactsChoice, StepContext, StepResult, WizardState}; + +pub(crate) async fn run(state: &mut WizardState, _ctx: &StepContext) -> Result { + eprintln!( + "\n {} Setting up contacts for registration", + style("👤").bold() + ); + + // Try to load existing contacts.toml. + let saved = contacts::load().ok(); + let has_saved = saved + .as_ref() + .map(|f| f.get(Role::Registrant).is_some()) + .unwrap_or(false); + + let choice = if has_saved { + let options = vec![ + "Use saved contacts from contacts.toml", + "Use account default contacts (no file needed)", + "Enter contacts manually", + "↩ Go back", + ]; + let selection = Select::new() + .with_prompt("How would you like to supply contacts?") + .items(&options) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 0 => { + let file = saved.expect("checked above"); + display_saved_contacts(&file); + ContactsChoice::FromFile(file) + } + 1 => ContactsChoice::AccountDefault, + 2 => collect_contacts_interactively()?, + 3 => return Ok(StepResult::Back), + _ => unreachable!(), + } + } else { + let options = vec![ + "Use account default contacts", + "Enter contacts manually", + "↩ Go back", + ]; + let selection = Select::new() + .with_prompt("How would you like to supply contacts?") + .items(&options) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 0 => ContactsChoice::AccountDefault, + 1 => collect_contacts_interactively()?, + 2 => return Ok(StepResult::Back), + _ => unreachable!(), + } + }; + + match &choice { + ContactsChoice::AccountDefault => { + eprintln!( + " {} Using account default contacts", + style("✓").green().bold() + ); + } + ContactsChoice::FromFile(_) => { + eprintln!( + " {} Using saved contacts from contacts.toml", + style("✓").green().bold() + ); + } + ContactsChoice::Manual(_) => { + eprintln!( + " {} Contacts entered successfully", + style("✓").green().bold() + ); + } + } + + state.contacts = choice; + Ok(StepResult::Continue) +} + +fn display_saved_contacts(file: &ContactsFile) { + for role in [Role::Registrant, Role::Admin, Role::Billing, Role::Tech] { + if let Some(c) = file.get(role) { + eprintln!( + " {} {}: {} {} <{}>", + style("•").dim(), + style(role.label()).bold(), + c.name_first, + c.name_last, + c.email + ); + } + } +} + +fn collect_contacts_interactively() -> Result { + eprintln!("\n Enter registrant contact details (other roles will use account defaults):"); + + let name_first = prompt_required("First name")?; + let name_last = prompt_required("Last name")?; + let email = prompt_validated("Email", validate_email)?; + let phone = prompt_validated("Phone (e.g. +1.4805551212)", validate_phone)?; + let organization = prompt_optional("Organization (optional, press Enter to skip)")?; + let address1 = prompt_required("Address line 1")?; + let address2 = prompt_optional("Address line 2 (optional, press Enter to skip)")?; + let city = prompt_required("City")?; + let state_prov = prompt_required("State/Province")?; + let postal_code = prompt_required("Postal code")?; + let country = prompt_validated("Country code (2-letter ISO, e.g. US)", validate_country)?; + + let contact = Contact { + name_first, + name_last, + email, + phone, + organization, + address1, + address2, + city, + state: state_prov, + postal_code, + country, + }; + + // Offer to save for future use. + let save = Confirm::new() + .with_prompt("Save these contacts to contacts.toml for future registrations?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if save { + if let Err(e) = save_contact_to_file(&contact) { + eprintln!( + " {} Could not save contacts: {e}", + style("⚠").yellow().bold() + ); + } else if let Some(path) = contacts::contacts_path() { + eprintln!( + " {} Saved to {}", + style("✓").green().bold(), + style(path.display()).dim() + ); + } + } + + let file = ContactsFile { + registrant: Some(contact), + admin: None, + billing: None, + tech: None, + }; + + Ok(ContactsChoice::Manual(file)) +} + +fn prompt_required(label: &str) -> Result { + let value: String = Input::new() + .with_prompt(format!(" Enter {label}")) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliCoreError::message(format!("{label} cannot be empty"))); + } + Ok(trimmed) +} + +fn prompt_optional(label: &str) -> Result> { + let value: String = Input::new() + .with_prompt(format!(" Enter {label} (optional)")) + .allow_empty(true) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed)) + } +} + +fn prompt_validated( + label: &str, + validate: fn(&str) -> std::result::Result<(), String>, +) -> Result { + loop { + let value: String = Input::new() + .with_prompt(format!(" Enter {label}")) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + eprintln!(" This field is required."); + continue; + } + match validate(&trimmed) { + Ok(()) => return Ok(trimmed), + Err(msg) => { + eprintln!(" {}", style(&msg).red()); + continue; + } + } + } +} + +fn validate_email(email: &str) -> std::result::Result<(), String> { + if email.contains('@') && email.contains('.') && email.len() >= 5 { + Ok(()) + } else { + Err("Invalid email format (expected user@domain.tld)".to_owned()) + } +} + +fn validate_phone(phone: &str) -> std::result::Result<(), String> { + // Accept anything the phonenumber crate can parse (validated fully at + // to_api() time); here we just do a basic format check. + if phone.len() >= 7 + && phone.chars().all(|c| { + c.is_ascii_digit() + || c == '+' + || c == '.' + || c == '-' + || c == ' ' + || c == '(' + || c == ')' + }) + { + Ok(()) + } else { + Err( + "Invalid phone format (expected something like +1.4805551212 or (480) 555-1212)" + .to_owned(), + ) + } +} + +fn validate_country(code: &str) -> std::result::Result<(), String> { + let upper = code.to_ascii_uppercase(); + if upper == "C2" || (upper.len() == 2 && upper.bytes().all(|b| b.is_ascii_uppercase())) { + Ok(()) + } else { + Err("Expected a two-letter ISO country code (e.g. US, GB, CA)".to_owned()) + } +} + +/// Save a contact as the registrant in contacts.toml. +pub(crate) fn save_contact_to_file(contact: &Contact) -> std::result::Result<(), String> { + let path = contacts::contacts_path() + .ok_or_else(|| "could not determine config directory".to_owned())?; + + // Ensure parent directory exists. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("could not create config directory: {e}"))?; + } + + let toml_content = format!( + r#"# gddy domain registration contacts +# Saved by `gddy domain register` interactive wizard. + +[registrant] +name_first = "{first}" +name_last = "{last}" +email = "{email}" +phone = "{phone}" +{org}address1 = "{addr1}" +{addr2}city = "{city}" +state = "{state}" +postal_code = "{postal}" +country = "{country}" +"#, + first = escape_toml(&contact.name_first), + last = escape_toml(&contact.name_last), + email = escape_toml(&contact.email), + phone = escape_toml(&contact.phone), + org = contact + .organization + .as_ref() + .map(|o| format!("organization = \"{}\"\n", escape_toml(o))) + .unwrap_or_default(), + addr1 = escape_toml(&contact.address1), + addr2 = contact + .address2 + .as_ref() + .map(|a| format!("address2 = \"{}\"\n", escape_toml(a))) + .unwrap_or_default(), + city = escape_toml(&contact.city), + state = escape_toml(&contact.state), + postal = escape_toml(&contact.postal_code), + country = escape_toml(&contact.country), + ); + + std::fs::write(&path, toml_content) + .map_err(|e| format!("could not write {}: {e}", path.display())) +} + +fn escape_toml(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[test] + fn validate_email_accepts_valid() { + assert!(validate_email("user@example.com").is_ok()); + assert!(validate_email("a@b.c").is_ok()); + } + + #[test] + fn validate_email_rejects_invalid() { + assert!(validate_email("notanemail").is_err()); + assert!(validate_email("@.").is_err()); + assert!(validate_email("").is_err()); + } + + #[test] + fn validate_phone_accepts_common_formats() { + assert!(validate_phone("+1.4805551212").is_ok()); + assert!(validate_phone("(480) 555-1212").is_ok()); + assert!(validate_phone("+44 7793 601890").is_ok()); + } + + #[test] + fn validate_phone_rejects_garbage() { + assert!(validate_phone("abc").is_err()); + assert!(validate_phone("").is_err()); + } + + #[test] + fn validate_country_accepts_iso_codes() { + assert!(validate_country("US").is_ok()); + assert!(validate_country("us").is_ok()); + assert!(validate_country("GB").is_ok()); + assert!(validate_country("C2").is_ok()); + } + + #[test] + fn validate_country_rejects_invalid() { + assert!(validate_country("USA").is_err()); + assert!(validate_country("1").is_err()); + assert!(validate_country("").is_err()); + } + + #[test] + fn escape_toml_handles_special_chars() { + assert_eq!(escape_toml(r#"hello "world""#), r#"hello \"world\""#); + assert_eq!(escape_toml(r"path\to"), r"path\\to"); + } + + #[test] + fn save_contact_roundtrip() { + let contact = Contact { + name_first: "Ada".to_owned(), + name_last: "Lovelace".to_owned(), + email: "ada@example.com".to_owned(), + phone: "+1.4805551212".to_owned(), + organization: Some("Engines Inc".to_owned()), + address1: "1 Bletchley Park".to_owned(), + address2: Some("Suite 100".to_owned()), + city: "Tempe".to_owned(), + state: "AZ".to_owned(), + postal_code: "85281".to_owned(), + country: "US".to_owned(), + }; + + let tmp = NamedTempFile::new().expect("tmpfile"); + let path = tmp.path().to_path_buf(); + + // Write to a temp file to verify the generated TOML is valid. + let toml_content = format!( + r#"[registrant] +name_first = "{first}" +name_last = "{last}" +email = "{email}" +phone = "{phone}" +organization = "{org}" +address1 = "{addr1}" +address2 = "{addr2}" +city = "{city}" +state = "{state}" +postal_code = "{postal}" +country = "{country}" +"#, + first = escape_toml(&contact.name_first), + last = escape_toml(&contact.name_last), + email = escape_toml(&contact.email), + phone = escape_toml(&contact.phone), + org = escape_toml(contact.organization.as_deref().unwrap_or("")), + addr1 = escape_toml(&contact.address1), + addr2 = escape_toml(contact.address2.as_deref().unwrap_or("")), + city = escape_toml(&contact.city), + state = escape_toml(&contact.state), + postal = escape_toml(&contact.postal_code), + country = escape_toml(&contact.country), + ); + + std::fs::write(&path, &toml_content).expect("write"); + + // Parse back and verify. + let raw = std::fs::read_to_string(&path).expect("read"); + let parsed: ContactsFile = toml::from_str(&raw).expect("parse"); + let registrant = parsed.get(Role::Registrant).expect("registrant present"); + assert_eq!(registrant.name_first, "Ada"); + assert_eq!(registrant.name_last, "Lovelace"); + assert_eq!(registrant.email, "ada@example.com"); + assert_eq!(registrant.city, "Tempe"); + assert_eq!(registrant.country, "US"); + } +} diff --git a/rust/src/domain/register/steps/discovery.rs b/rust/src/domain/register/steps/discovery.rs new file mode 100644 index 00000000..3957623d --- /dev/null +++ b/rust/src/domain/register/steps/discovery.rs @@ -0,0 +1,290 @@ +//! Step 1: Domain discovery — prompt for a domain name, check availability, +//! and offer alternatives when the requested name is taken. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use dialoguer::{Input, Select}; + +use crate::domain::common::{ + api_error, make_client_with_cred, term_for_period, validate_domain_name, +}; + +use crate::retry::with_retry; + +use super::super::wizard::{StepContext, StepResult, WizardState}; + +/// Maximum suggestions to show when a domain is taken. +const MAX_SUGGESTIONS: usize = 10; + +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { + // If we already have a domain from a previous step or CLI arg, skip prompting. + if state.domain.is_some() && state.available { + return Ok(StepResult::Continue); + } + + let domain = match &state.domain { + Some(d) => d.clone(), + None => prompt_domain_name()?, + }; + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + // Check availability with retry for transient failures. + let availability = match with_retry("availability check", 3, || { + let c = &client; + let d = domain.as_str(); + async move { c.get_domain_availability().domain(d).send().await } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain availability check", debug, e).await), + }; + + let available = availability.available.unwrap_or(false); + state.domain = Some(domain.clone()); + + if available { + state.available = true; + let prices = availability.prices.unwrap_or_default(); + if let Some(tp) = term_for_period(&prices, 1) { + state.price = tp + .price + .as_ref() + .and_then(crate::domain::common::format_money); + state.currency = tp + .price + .as_ref() + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()); + } + eprintln!( + " {} {} is available!", + style("✓").green().bold(), + style(&domain).cyan().bold() + ); + return Ok(StepResult::Continue); + } + + // Domain is taken — offer suggestions. + eprintln!( + " {} {} is not available.", + style("✗").red().bold(), + style(&domain).cyan() + ); + + let suggestions = fetch_suggestions(&client, &domain, debug).await?; + if suggestions.is_empty() { + eprintln!(" No alternative suggestions found."); + return prompt_retry_or_cancel(state); + } + + select_from_suggestions(state, &suggestions) +} + +fn prompt_domain_name() -> Result { + let input: String = Input::new() + .with_prompt("Enter domain name to register") + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_domain_name(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_domain_name(&input) +} + +async fn fetch_suggestions( + client: &domains_client::Client, + domain: &str, + debug: bool, +) -> Result> { + let page_size = + std::num::NonZeroI64::new(MAX_SUGGESTIONS as i64).expect("MAX_SUGGESTIONS is non-zero"); + let resp = match with_retry("suggestions", 3, || { + let c = client; + let d = domain; + async move { + c.suggest_domains() + .query(d) + .page_size(page_size) + .send() + .await + } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain suggestion", debug, e).await), + }; + + Ok(collect_suggestions(&resp.items, MAX_SUGGESTIONS)) +} + +fn select_from_suggestions( + state: &mut WizardState, + suggestions: &[SuggestionEntry], +) -> Result { + let items: Vec = suggestions + .iter() + .map(|s| match (&s.price, &s.currency) { + (Some(p), Some(c)) => format!("{} ({} {})", s.domain, p, c), + (Some(p), None) => format!("{} ({})", s.domain, p), + _ => s.domain.clone(), + }) + .chain(std::iter::once("↩ Try a different domain".to_string())) + .chain(std::iter::once("✗ Cancel".to_string())) + .collect(); + + let selection = Select::new() + .with_prompt("Choose a domain") + .items(&items) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if selection == items.len() - 1 { + return Ok(StepResult::Cancel); + } + if selection == items.len() - 2 { + state.domain = None; + state.available = false; + return Ok(StepResult::Back); + } + + let chosen = &suggestions[selection]; + state.domain = Some(chosen.domain.clone()); + state.available = true; + state.price = chosen.price.clone(); + state.currency = chosen.currency.clone(); + eprintln!( + " {} Selected {}", + style("✓").green().bold(), + style(&chosen.domain).cyan().bold() + ); + Ok(StepResult::Continue) +} + +fn prompt_retry_or_cancel(state: &mut WizardState) -> Result { + let items = vec!["Try a different domain", "Cancel"]; + let selection = Select::new() + .with_prompt("What would you like to do?") + .items(&items) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if selection == 0 { + state.domain = None; + state.available = false; + Ok(StepResult::Back) + } else { + Ok(StepResult::Cancel) + } +} + +struct SuggestionEntry { + domain: String, + price: Option, + currency: Option, +} + +/// Extract unique suggestions from raw API items, capped at `max`. +/// Factored out for testability. +fn collect_suggestions( + items: &[domains_client::types::Suggestion], + max: usize, +) -> Vec { + let mut all: Vec = Vec::new(); + for item in items { + if all.len() >= max { + break; + } + let Some(name) = item.domain.as_deref() else { + continue; + }; + if all.iter().any(|s| s.domain == name) { + continue; + } + let prices = item.prices.as_deref().unwrap_or_default(); + let price = term_for_period(prices, 1) + .and_then(|tp| tp.price.as_ref()) + .and_then(crate::domain::common::format_money); + let currency = term_for_period(prices, 1) + .and_then(|tp| tp.price.as_ref()) + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()); + all.push(SuggestionEntry { + domain: name.to_owned(), + price, + currency, + }); + } + all +} + +#[cfg(test)] +mod tests { + use super::*; + use domains_client::types::Suggestion; + + fn make_suggestion(domain: &str) -> Suggestion { + Suggestion { + domain: Some(domain.to_string()), + inventory: None, + prices: None, + } + } + + #[test] + fn collect_suggestions_deduplicates() { + let items = vec![ + make_suggestion("a.com"), + make_suggestion("b.com"), + make_suggestion("a.com"), // duplicate + make_suggestion("c.com"), + ]; + let results = collect_suggestions(&items, 10); + assert_eq!(results.len(), 3); + assert_eq!(results[0].domain, "a.com"); + assert_eq!(results[1].domain, "b.com"); + assert_eq!(results[2].domain, "c.com"); + } + + #[test] + fn collect_suggestions_caps_at_max() { + let items: Vec = (0..20) + .map(|i| make_suggestion(&format!("domain{i}.com"))) + .collect(); + let results = collect_suggestions(&items, MAX_SUGGESTIONS); + assert_eq!(results.len(), MAX_SUGGESTIONS); + } + + #[test] + fn collect_suggestions_skips_items_without_domain() { + let items = vec![ + Suggestion { + domain: None, + inventory: None, + prices: None, + }, + make_suggestion("valid.com"), + Suggestion { + domain: None, + inventory: None, + prices: None, + }, + ]; + let results = collect_suggestions(&items, 10); + assert_eq!(results.len(), 1); + assert_eq!(results[0].domain, "valid.com"); + } + + #[test] + fn collect_suggestions_empty_input() { + let results = collect_suggestions(&[], 10); + assert!(results.is_empty()); + } +} diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs new file mode 100644 index 00000000..9f792b1c --- /dev/null +++ b/rust/src/domain/register/steps/execute.rs @@ -0,0 +1,255 @@ +//! Step 5: Execute — submit the registration using the cached quote, poll the +//! async operation, and display the result. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use indicatif::{ProgressBar, ProgressStyle}; + +use domains_client::types; + +use crate::domain::common::{ + api_error, format_operation_error, is_terminal_status, make_client_with_cred, +}; +use crate::quote_cache; + +use crate::retry::with_retry; + +use super::super::wizard::{StepContext, StepResult, WizardState}; + +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + let quote_token = state + .quote_token + .as_ref() + .ok_or_else(|| CliCoreError::message("no quote token available"))? + .clone(); + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + // Validate credential is a customer identity. + let _customer_id = ctx + .credential + .sub + .strip_prefix("customer:") + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + CliCoreError::message(format!( + "the OAuth token's subject ({:?}) is not a customer identity; \ + domain registration needs a customer-scoped token", + ctx.credential.sub + )) + })?; + + // Build consent. + let agreement_types: Vec = state + .agreement_types + .iter() + .map(|t| { + t.parse::().map_err(|_| { + CliCoreError::message(format!( + "unrecognized agreement type ({t:?}); re-run the wizard for a fresh quote" + )) + }) + }) + .collect::>>()?; + + let period_nz = std::num::NonZeroU64::new(state.period) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + let (profile, acknowledged_fees) = match quote_cache::get("e_token) { + quote_cache::Lookup::Found(cached) => { + let prof = cached + .profile + .as_ref() + .map(|v| serde_json::from_value::(v.clone())) + .transpose() + .map_err(|e| { + CliCoreError::message(format!("corrupt cached profile: {e}; re-run the wizard")) + })?; + let fees = match cached.fees.as_ref() { + Some(v) => serde_json::from_value::>(v.clone()).map_err(|e| { + CliCoreError::message(format!( + "the cached quote is corrupt or from an older CLI version \ + (could not read its fees: {e}); re-run the wizard for a fresh quote." + )) + })?, + None => vec![], + }; + (prof, fees) + } + _ => (None, vec![]), + }; + + let consent = types::Consent { + agreed_at: types::DateTime( + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ), + agreed_by: None, + agreement_types, + acknowledged_fees, + }; + + let registration = types::Registration { + consent, + created_at: None, + domain: domain.clone(), + expires_at: None, + fees: vec![], + links: vec![], + operation_id: None, + order_id: None, + period: period_nz, + price: None, + profile, + profile_id: None, + quote_token: Some(types::Uuid(quote_token.clone())), + registration_id: None, + status: None, + updated_at: None, + }; + + // Show spinner during registration. + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template(" {spinner} {msg}") + .expect("valid template"), + ); + spinner.set_message(format!("Registering {}...", domain)); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + + let idempotency_key = match quote_cache::get("e_token) { + quote_cache::Lookup::Found(cached) => cached + .idempotency_key + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + _ => uuid::Uuid::new_v4().to_string(), + }; + let accepted = match with_retry("registration", 3, || { + let c = &client; + let key = &idempotency_key; + let reg = registration.clone(); + async move { + c.register_domain() + .idempotency_key(key) + .body(reg) + .send() + .await + } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => { + spinner.finish_and_clear(); + return Err(api_error("domain register", debug, e).await); + } + }; + + // Consume the quote token. + quote_cache::remove("e_token); + + // Poll operation to terminal state. + let mut status = accepted + .status + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "SUBMITTED".to_string()); + let operation_id = accepted.operation_id.clone(); + let mut operation_error: Option = None; + + if let Some(op_id) = operation_id.as_ref() { + spinner.set_message(format!("Waiting for registry ({})", domain)); + let mut timed_out = false; + for _ in 0..20 { + if is_terminal_status(&status) { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + match client + .get_operation() + .operation_id(op_id.clone()) + .send() + .await + { + Ok(r) => { + let op = r.into_inner(); + if let Some(s) = op.status { + status = s.to_string(); + } + operation_error = op.error; + } + Err(_) => break, + } + } + if !is_terminal_status(&status) { + timed_out = true; + } + if timed_out { + spinner.finish_and_clear(); + eprintln!( + "\n {} The registration was submitted successfully but the registry hasn't \ + confirmed yet.", + style("⏳").bold() + ); + eprintln!(" Your domain will be registered — this is normal for some TLDs."); + eprintln!( + " Check progress with: gddy domain operation status {}", + op_id + ); + } + } + + spinner.finish_and_clear(); + + // Display result. + if status == "FAILED" { + let detail = format_operation_error(operation_error.as_ref()); + return Err(CliCoreError::message(format!( + "registration for {domain} failed{detail}; no domain was registered. \ + Please try again." + ))); + } + + if status == "COMPLETED" { + eprintln!( + "\n {} {} has been registered!", + style("🎉").bold(), + style(&domain).green().bold() + ); + } else if !is_terminal_status(&status) { + // Already printed timeout message above; skip duplicate output. + } else { + eprintln!( + "\n {} Registration submitted for {} (status: {})", + style("⏳").bold(), + style(&domain).cyan(), + status + ); + if let Some(op) = &operation_id { + eprintln!( + " Check progress with: gddy domain operation status {}", + op + ); + } + } + + if let Some(price) = &state.price { + let currency = state.currency.as_deref().unwrap_or(""); + eprintln!(" Charged: {} {}", price, currency); + } + + // Next-step commands are owned by the CommandResult envelope (see + // `build_result`) so human/JSON output has a single Next steps footer — + // printing them here again would duplicate that footer when the wizard + // returns through `domain suggest`/`available`/`quote`. + + state.status = Some(status); + state.operation_id = operation_id.map(|o| o.to_string()); + + Ok(StepResult::Continue) +} diff --git a/rust/src/domain/register/steps/mod.rs b/rust/src/domain/register/steps/mod.rs new file mode 100644 index 00000000..87469fe4 --- /dev/null +++ b/rust/src/domain/register/steps/mod.rs @@ -0,0 +1,7 @@ +//! Wizard step implementations for the domain registration flow. + +pub(super) mod contacts; +pub(super) mod discovery; +pub(super) mod execute; +pub(super) mod options; +pub(super) mod review; diff --git a/rust/src/domain/register/steps/options.rs b/rust/src/domain/register/steps/options.rs new file mode 100644 index 00000000..3e114f9a --- /dev/null +++ b/rust/src/domain/register/steps/options.rs @@ -0,0 +1,117 @@ +//! Step 2: Registration options — period, privacy, auto-renew, custom +//! nameservers. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use dialoguer::{Confirm, Input, Select}; + +use super::super::wizard::{StepContext, StepResult, WizardState}; + +/// Available registration periods (years). +const PERIOD_OPTIONS: &[u64] = &[1, 2, 3, 5, 10]; + +pub(crate) async fn run(state: &mut WizardState, _ctx: &StepContext) -> Result { + eprintln!( + "\n {} Configuring registration for {}", + style("⚙").bold(), + style(state.domain.as_deref().unwrap_or("unknown")).cyan() + ); + + // Period selection. + let period_labels: Vec = PERIOD_OPTIONS + .iter() + .map(|p| { + if *p == 1 { + "1 year".to_string() + } else { + format!("{p} years") + } + }) + .collect(); + + let period_idx = Select::new() + .with_prompt("Registration period") + .items(&period_labels) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + state.period = PERIOD_OPTIONS[period_idx]; + + // Privacy protection. + state.privacy = Confirm::new() + .with_prompt("Enable WHOIS privacy protection?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + // Auto-renew. + state.auto_renew = Confirm::new() + .with_prompt("Enable auto-renewal?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + // Custom nameservers (optional). + let custom_ns = Confirm::new() + .with_prompt("Use custom nameservers? (No = GoDaddy defaults)") + .default(false) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if custom_ns { + state.nameservers = prompt_nameservers()?; + } else { + state.nameservers = Vec::new(); + } + + eprintln!( + " {} Options configured: {} year(s), privacy={}, auto-renew={}", + style("✓").green().bold(), + state.period, + if state.privacy { "on" } else { "off" }, + if state.auto_renew { "on" } else { "off" }, + ); + + let choices = vec!["Continue", "↩ Go back to domain selection"]; + let selection = Select::new() + .items(&choices) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if selection == 1 { + return Ok(StepResult::Back); + } + + Ok(StepResult::Continue) +} + +fn prompt_nameservers() -> Result> { + let mut nameservers = Vec::new(); + eprintln!(" Enter nameservers (empty line to finish, min 2):"); + loop { + let ns: String = Input::new() + .with_prompt(format!(" Enter NS {}", nameservers.len() + 1)) + .allow_empty(true) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if ns.is_empty() { + if nameservers.len() < 2 && !nameservers.is_empty() { + eprintln!(" At least 2 nameservers are required."); + continue; + } + break; + } + + // Validate nameserver format. + match crate::domain::common::validate_domain_name(&ns) { + Ok(valid) => nameservers.push(valid), + Err(e) => { + eprintln!(" Invalid nameserver: {e}"); + continue; + } + } + } + Ok(nameservers) +} diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs new file mode 100644 index 00000000..7597a290 --- /dev/null +++ b/rust/src/domain/register/steps/review.rs @@ -0,0 +1,498 @@ +//! Step 4: Review — fetch a quote, display agreements, show order summary, and +//! request confirmation before executing. Also handles 402 Payment Required +//! by offering to open the browser for payment method setup. + +use cli_engine::{CliCoreError, Result}; +use console::{Alignment, pad_str, style}; +use dialoguer::{Confirm, Select}; + +use domains_client::types; + +use crate::contacts::Role; +use crate::domain::common::{ + api_error, format_money, make_client_with_cred, period_label, validate_nameserver_hosts, +}; +use crate::environments; +use crate::quote_cache; + +use crate::retry::with_retry; + +use super::super::wizard::{ContactsChoice, StepContext, StepResult, WizardState}; + +/// Inner content width for the order-summary box (between the `│ ` and ` │`). +const SUMMARY_INNER_WIDTH: usize = 38; + +/// One padded line of the order-summary box. Uses `console::pad_str` so ANSI +/// color codes and wide glyphs (emoji) don't shift the right border. +fn summary_line(content: &str) -> String { + format!( + " │ {}│", + pad_str(content, SUMMARY_INNER_WIDTH, Alignment::Left, None) + ) +} + +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + + eprintln!( + "\n {} Fetching quote for {}...", + style("$").bold(), + style(&domain).cyan() + ); + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + let period_nz = std::num::NonZeroU64::new(state.period) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + // Build the registration profile for the quote. + let name_servers = if state.nameservers.is_empty() { + None + } else { + let validated = validate_nameserver_hosts(state.nameservers.clone())?; + Some(types::NameServers( + validated + .iter() + .map(|h| types::NameserverHostname(h.clone())) + .collect(), + )) + }; + + // Resolve contacts from the wizard state. + let contacts = build_contacts_for_profile(&state.contacts)?; + + let profile = types::InlineRegistrationProfile { + auto_renew: Some(state.auto_renew), + contacts, + name_servers, + privacy: Some(state.privacy), + }; + let profile_json = serde_json::to_value(&profile).map_err(|e| { + CliCoreError::message(format!("could not serialize registration profile: {e}")) + })?; + + let quote_body = types::QuoteDomainRegistrationBody { + domain: domain.clone(), + period: period_nz, + profile: Some(profile), + profile_id: None, + }; + let quote = match with_retry("quote", 3, || { + let c = &client; + let b = quote_body.clone(); + async move { c.quote_domain_registration().body(b).send().await } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => { + let err = api_error("domain quote", debug, e).await; + if is_payment_error(&err) { + return handle_payment_required(ctx).await; + } + return Err(err); + } + }; + + // Extract pricing and agreement info. + let price_str = quote.price.as_ref().and_then(format_money); + let renewal_str = quote.renewal_price.as_ref().and_then(format_money); + let currency = quote + .price + .as_ref() + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()) + .unwrap_or_default(); + + let agreements = quote.required_agreements.clone().unwrap_or_default(); + let agreement_titles: Vec = agreements + .iter() + .map(|a| { + let title = a.title.as_deref().unwrap_or("(untitled)"); + match a.url.as_deref() { + Some(url) => format!("{title} ({url})"), + None => title.to_owned(), + } + }) + .collect(); + let agreement_types: Vec = agreements + .iter() + .filter_map(|a| a.agreement_type.as_ref().map(|t| t.to_string())) + .collect(); + + // Display order summary. Pad by visible width (not byte length) so + // styled/colored fields keep the right border aligned. + let w = SUMMARY_INNER_WIDTH + 1; // border fill between ┌ and ┐ + eprintln!("\n ┌{}┐", "─".repeat(w)); + eprintln!( + "{}", + summary_line(&format!("{}", style("Order Summary").bold())) + ); + eprintln!(" ├{}┤", "─".repeat(w)); + eprintln!( + "{}", + summary_line(&format!("Domain: {}", style(&domain).cyan().bold())) + ); + eprintln!( + "{}", + summary_line(&format!("Period: {}", period_label(state.period))) + ); + if let Some(p) = &price_str { + eprintln!( + "{}", + summary_line(&format!( + "Price: {} {}", + style(p).green().bold(), + currency + )) + ); + } + if let Some(r) = &renewal_str { + eprintln!( + "{}", + summary_line(&format!("Renewal: {r} {currency}/yr")) + ); + } + eprintln!( + "{}", + summary_line(&format!( + "Privacy: {}", + if state.privacy { "Yes" } else { "No" } + )) + ); + eprintln!( + "{}", + summary_line(&format!( + "Auto-renew: {}", + if state.auto_renew { "Yes" } else { "No" } + )) + ); + if !state.nameservers.is_empty() { + eprintln!( + "{}", + summary_line(&format!("Nameservers: {}", state.nameservers.join(", "))) + ); + } + eprintln!(" └{}┘", "─".repeat(w)); + + // Show agreements. + if !agreement_titles.is_empty() { + eprintln!("\n Legal agreements:"); + for title in &agreement_titles { + eprintln!(" • {title}"); + } + } + + // Confirm. + let choices = vec![ + format!( + "✓ I agree to the terms above and authorize a charge of {} {}", + price_str.as_deref().unwrap_or("the quoted price"), + currency + ), + "↩ Go back and change options".to_string(), + "✗ Cancel — do not purchase".to_string(), + ]; + let selection = Select::new() + .with_prompt("By proceeding you accept the legal agreements listed above") + .items(&choices) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 1 => return Ok(StepResult::Back), + 2 => return Ok(StepResult::Cancel), + _ => {} // 0 = proceed + } + + // Cache the quote for the execute step. + let quote_token = quote + .quote_token + .as_ref() + .ok_or_else(|| CliCoreError::message("the quote returned no token (domain unavailable?)"))? + .to_string(); + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let fees_json = match quote.fees.as_ref().filter(|f| !f.is_empty()) { + Some(fees) => Some(serde_json::to_value(fees).map_err(|e| { + CliCoreError::message(format!( + "could not serialize the quote's fees for the quote cache: {e}" + )) + })?), + None => None, + }; + quote_cache::save( + "e_token, + quote_cache::CachedQuote { + domain: quote.domain.clone().unwrap_or_else(|| domain.clone()), + period: quote.period.map_or(state.period, |p| p.get()), + price: price_str.clone(), + currency: Some(currency.clone()), + agreement_titles: agreement_titles.clone(), + agreement_types: agreement_types.clone(), + profile: Some(profile_json), + idempotency_key: Some(idempotency_key), + expires_at: quote.expires_at.as_ref().map(|e| e.to_string()), + fees: fees_json, + }, + )?; + + state.quote_token = Some(quote_token); + state.price = price_str; + state.currency = Some(currency); + state.agreement_titles = agreement_titles; + state.agreement_types = agreement_types; + + Ok(StepResult::Continue) +} + +/// Non-interactive variant: fetches a quote and caches it without prompting +/// for confirmation. Callers must have already validated `--agree` and +/// `--confirm` flags before reaching this point. +pub(crate) async fn run_non_interactive( + state: &mut WizardState, + ctx: &StepContext, +) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + let period_nz = std::num::NonZeroU64::new(state.period) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + let name_servers = if state.nameservers.is_empty() { + None + } else { + let validated = validate_nameserver_hosts(state.nameservers.clone())?; + Some(types::NameServers( + validated + .iter() + .map(|h| types::NameserverHostname(h.clone())) + .collect(), + )) + }; + + let contacts = build_contacts_for_profile(&state.contacts)?; + + let profile = types::InlineRegistrationProfile { + auto_renew: Some(state.auto_renew), + contacts, + name_servers, + privacy: Some(state.privacy), + }; + let profile_json = serde_json::to_value(&profile).map_err(|e| { + CliCoreError::message(format!("could not serialize registration profile: {e}")) + })?; + + let quote_body = types::QuoteDomainRegistrationBody { + domain: domain.clone(), + period: period_nz, + profile: Some(profile), + profile_id: None, + }; + let quote = match with_retry("quote", 3, || { + let c = &client; + let b = quote_body.clone(); + async move { c.quote_domain_registration().body(b).send().await } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => { + let err = api_error("domain quote", debug, e).await; + if is_payment_error(&err) { + return Err(CliCoreError::message( + "no usable payment method on file; add one at \ + https://account.godaddy.com/payment-methods before retrying", + )); + } + return Err(err); + } + }; + + let price_str = quote.price.as_ref().and_then(format_money); + let currency = quote + .price + .as_ref() + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()) + .unwrap_or_default(); + + let agreements = quote.required_agreements.clone().unwrap_or_default(); + let agreement_titles: Vec = agreements + .iter() + .map(|a| { + let title = a.title.as_deref().unwrap_or("(untitled)"); + match a.url.as_deref() { + Some(url) => format!("{title} ({url})"), + None => title.to_owned(), + } + }) + .collect(); + let agreement_types: Vec = agreements + .iter() + .filter_map(|a| a.agreement_type.as_ref().map(|t| t.to_string())) + .collect(); + + let quote_token = quote + .quote_token + .as_ref() + .ok_or_else(|| CliCoreError::message("the quote returned no token (domain unavailable?)"))? + .to_string(); + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let fees_json = match quote.fees.as_ref().filter(|f| !f.is_empty()) { + Some(fees) => Some(serde_json::to_value(fees).map_err(|e| { + CliCoreError::message(format!( + "could not serialize the quote's fees for the quote cache: {e}" + )) + })?), + None => None, + }; + quote_cache::save( + "e_token, + quote_cache::CachedQuote { + domain: quote.domain.clone().unwrap_or_else(|| domain.clone()), + period: quote.period.map_or(state.period, |p| p.get()), + price: price_str.clone(), + currency: Some(currency.clone()), + agreement_titles: agreement_titles.clone(), + agreement_types: agreement_types.clone(), + profile: Some(profile_json), + idempotency_key: Some(idempotency_key), + expires_at: quote.expires_at.as_ref().map(|e| e.to_string()), + fees: fees_json, + }, + )?; + + state.quote_token = Some(quote_token); + state.price = price_str; + state.currency = Some(currency); + state.agreement_titles = agreement_titles; + state.agreement_types = agreement_types; + + Ok(StepResult::Continue) +} + +/// Convert the wizard's contacts choice into the API's `Contacts` struct. +fn build_contacts_for_profile(choice: &ContactsChoice) -> Result> { + let file = match choice { + ContactsChoice::AccountDefault => return Ok(None), + ContactsChoice::FromFile(f) | ContactsChoice::Manual(f) => f, + }; + + let to_api = |role| file.to_api(role).map_err(CliCoreError::message); + let registrant = to_api(Role::Registrant)?; + let admin = to_api(Role::Admin)?; + let billing = to_api(Role::Billing)?; + let tech = to_api(Role::Tech)?; + + let any_non_registrant = admin.is_some() || billing.is_some() || tech.is_some(); + match registrant { + Some(registrant) => Ok(Some(types::Contacts { + registrant, + admin, + billing, + tech, + })), + None if any_non_registrant => Err(CliCoreError::message( + "contacts define a non-registrant contact but no registrant; the API requires a \ + registrant when any contact is supplied", + )), + None => Ok(None), + } +} + +/// Check if a CLI error is a 402 Payment Required error. +fn is_payment_error(err: &CliCoreError) -> bool { + let msg = err.to_string(); + msg.contains("402") || msg.contains("INVALID_PAYMENT_INFO") || msg.contains("payment") +} + +/// Handle 402: inform the user and offer to open the payment methods page. +async fn handle_payment_required(ctx: &StepContext) -> Result { + eprintln!( + "\n {} No usable payment method found on your account.", + style("⚠").yellow().bold() + ); + eprintln!(" A credit card or Good-as-Gold balance is required for domain purchases."); + + let open_browser = Confirm::new() + .with_prompt("Open the GoDaddy payment methods page in your browser?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if open_browser { + let env = environments::resolve(&ctx.env)?; + let url = format!("{}/payment-methods/add-payment?plid=1", env.account_url); + if open::that(&url).is_err() { + eprintln!( + " Could not open browser. Visit: {}", + style(&url).underlined() + ); + } else { + eprintln!(" {} Browser opened.", style("✓").green().bold()); + } + } + + eprintln!("\n After adding a payment method, select '↩ Go back' to retry."); + + let retry_choices = vec!["↩ Go back and retry the quote", "✗ Cancel registration"]; + let selection = Select::new() + .with_prompt("What would you like to do?") + .items(&retry_choices) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 0 => Ok(StepResult::Back), + _ => Ok(StepResult::Cancel), + } +} + +#[cfg(test)] +mod tests { + use super::{SUMMARY_INNER_WIDTH, summary_line}; + use console::{measure_text_width, style}; + + #[test] + fn summary_line_keeps_right_border_aligned_with_ansi_styles() { + let plain = summary_line("Period: 2 years"); + let styled = summary_line(&format!( + "Domain: {}", + style("iguanahats.shop").cyan().bold() + )); + let priced = summary_line(&format!( + "Price: {} USD", + style("60.98").green().bold() + )); + + // Visible width (ANSI stripped) must match across plain and styled + // rows so the box's right `│` lines up in a real terminal. + let widths = [ + measure_text_width(&plain), + measure_text_width(&styled), + measure_text_width(&priced), + ]; + assert!( + widths.iter().all(|&w| w == widths[0]), + "visible widths drifted: {widths:?}\nplain={plain:?}\nstyled={styled:?}\npriced={priced:?}" + ); + // " │ " (4) + inner + "│" (1) + assert_eq!(widths[0], 4 + SUMMARY_INNER_WIDTH + 1); + assert!(plain.ends_with('│')); + assert!(styled.ends_with('│')); + assert!(priced.ends_with('│')); + } +} diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs new file mode 100644 index 00000000..ec81e487 --- /dev/null +++ b/rust/src/domain/register/wizard.rs @@ -0,0 +1,311 @@ +//! Wizard step-runner: manages forward/back navigation, state, and the step +//! header display for the domain registration wizard. + +use cli_engine::{Credential, Result}; +use console::style; + +use super::steps; + +/// The result of running a single wizard step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StepResult { + /// Advance to the next step. + Continue, + /// Go back to the previous step (or restart the current step if at step 0). + Back, + /// The user cancelled the wizard. + Cancel, +} + +/// Shared context passed to each wizard step. +pub(crate) struct StepContext { + pub credential: Credential, + pub env: String, + pub debug: bool, +} + +/// Accumulated state across all wizard steps. +#[derive(Debug, Clone, Default)] +pub(crate) struct WizardState { + // Step 1: Discovery + pub domain: Option, + pub available: bool, + + // Step 2: Options + pub period: u64, + pub privacy: bool, + pub auto_renew: bool, + pub nameservers: Vec, + + // Step 3: Contacts + pub contacts: ContactsChoice, + + // Step 4: Review (populated after quote) + pub quote_token: Option, + pub price: Option, + pub currency: Option, + pub agreement_titles: Vec, + pub agreement_types: Vec, + + // Step 5: Execute (populated after registration) + pub status: Option, + pub operation_id: Option, + + // Set when the wizard is cancelled by the user (not an error). + pub cancelled: bool, + // Set when the user navigated back past the entry step (not a cancellation). + pub backed_out: bool, +} + +/// How contacts are supplied for the registration. +#[derive(Debug, Clone, Default)] +pub(crate) enum ContactsChoice { + /// Use the account's default contacts (omit from request). + #[default] + AccountDefault, + /// Use contacts loaded from contacts.toml. + FromFile(crate::contacts::ContactsFile), + /// Contacts entered interactively during the wizard. + Manual(crate::contacts::ContactsFile), +} + +impl WizardState { + pub fn new() -> Self { + Self { + period: 1, + privacy: true, + auto_renew: true, + ..Default::default() + } + } + + /// Pre-populate from CLI flags for non-interactive fallback or partial entry. + pub fn with_domain(mut self, domain: Option) -> Self { + self.domain = domain; + self + } + + pub fn with_period(mut self, period: u64) -> Self { + self.period = period; + self + } + + pub fn with_privacy(mut self, privacy: bool) -> Self { + self.privacy = privacy; + self + } + + pub fn with_auto_renew(mut self, auto_renew: bool) -> Self { + self.auto_renew = auto_renew; + self + } + + pub fn with_nameservers(mut self, nameservers: Vec) -> Self { + self.nameservers = nameservers; + self + } +} + +/// Step metadata for the header display. +struct StepInfo { + name: &'static str, +} + +const STEPS: &[StepInfo] = &[ + StepInfo { name: "Discovery" }, + StepInfo { name: "Options" }, + StepInfo { name: "Contacts" }, + StepInfo { + name: "Review & Confirm", + }, + StepInfo { name: "Register" }, +]; + +/// Run the wizard starting at `start_at` step (0-indexed). +/// +/// Returns the final `WizardState` on success, or an error if the wizard is +/// cancelled or a step fails. +/// +/// When entered mid-flow (e.g. from `domain suggest` at Options), the header +/// counts only remaining steps — `Step 1/4: Options` rather than `Step 2/5` — +/// so the counter matches the work the user still has to do. +pub(crate) async fn run_wizard( + mut state: WizardState, + ctx: StepContext, + start_at: usize, +) -> Result { + let total_steps = STEPS.len(); + let mut current = start_at; + // Relative denominator: how many steps this entry point will show. + let display_total = total_steps.saturating_sub(start_at); + + loop { + if current >= total_steps { + break; + } + + let step = &STEPS[current]; + let display_num = current.saturating_sub(start_at) + 1; + eprintln!( + "\n {} Step {}/{}: {}", + style("─").dim(), + display_num, + display_total, + style(step.name).bold() + ); + + let step_result = match current { + 0 => steps::discovery::run(&mut state, &ctx).await, + 1 => steps::options::run(&mut state, &ctx).await, + 2 => steps::contacts::run(&mut state, &ctx).await, + 3 => steps::review::run(&mut state, &ctx).await, + 4 => steps::execute::run(&mut state, &ctx).await, + _ => unreachable!(), + }; + + let result = match step_result { + Ok(r) => r, + Err(e) if is_prompt_cancelled(&e) => { + eprintln!( + "\n {} Interrupted. No charges were made.", + style("✗").red().bold() + ); + state.cancelled = true; + return Ok(state); + } + Err(e) => return Err(e), + }; + + match result { + StepResult::Continue => { + current += 1; + } + StepResult::Back => { + if current == start_at { + // Already at the entry point — can't go further back. + // Signal that we backed out so the caller (bridge) can + // re-show its own selection UI. + state.backed_out = true; + return Ok(state); + } + current -= 1; + // If navigating back to Discovery, clear domain state so the + // step re-prompts for a domain name instead of short-circuiting. + if current == 0 { + state.domain = None; + state.available = false; + } + } + StepResult::Cancel => { + eprintln!( + "\n {} Wizard cancelled. No charges were made.", + style("✗").red().bold() + ); + state.cancelled = true; + return Ok(state); + } + } + } + + Ok(state) +} + +/// Detect if an error came from a cancelled prompt (Ctrl+C or EOF in dialoguer). +fn is_prompt_cancelled(err: &cli_engine::CliCoreError) -> bool { + let msg = err.to_string(); + msg.contains("prompt cancelled") || msg.contains("interrupted") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wizard_state_defaults_are_sensible() { + let state = WizardState::new(); + assert_eq!(state.period, 1); + assert!(state.privacy); + assert!(state.auto_renew); + assert!(state.domain.is_none()); + assert!(state.nameservers.is_empty()); + } + + #[test] + fn wizard_state_builder_methods_work() { + let state = WizardState::new() + .with_domain(Some("example.com".to_string())) + .with_period(2) + .with_privacy(false) + .with_auto_renew(false) + .with_nameservers(vec!["ns1.example.com".to_string()]); + assert_eq!(state.domain.as_deref(), Some("example.com")); + assert_eq!(state.period, 2); + assert!(!state.privacy); + assert!(!state.auto_renew); + assert_eq!(state.nameservers, vec!["ns1.example.com"]); + } + + #[test] + fn step_result_equality() { + assert_eq!(StepResult::Continue, StepResult::Continue); + assert_eq!(StepResult::Back, StepResult::Back); + assert_eq!(StepResult::Cancel, StepResult::Cancel); + assert_ne!(StepResult::Continue, StepResult::Cancel); + } + + #[test] + fn steps_metadata_has_expected_count() { + assert_eq!(STEPS.len(), 5); + assert_eq!(STEPS[0].name, "Discovery"); + assert_eq!(STEPS[1].name, "Options"); + assert_eq!(STEPS[2].name, "Contacts"); + assert_eq!(STEPS[3].name, "Review & Confirm"); + assert_eq!(STEPS[4].name, "Register"); + } + + #[test] + fn mid_flow_step_counter_is_relative_to_entry_point() { + // From suggest/available the wizard starts at Options (index 1): + // remaining steps are Options→Contacts→Review→Register → 4 total, + // and Options itself is display step 1. + let start_at = 1; + let display_total = STEPS.len().saturating_sub(start_at); + assert_eq!(display_total, 4); + assert_eq!(1usize.saturating_sub(start_at) + 1, 1); // Options → 1/4 + assert_eq!(4usize.saturating_sub(start_at) + 1, 4); // Register → 4/4 + + // From quote the wizard starts at Review (index 3): 2 remaining. + let start_at = 3; + let display_total = STEPS.len().saturating_sub(start_at); + assert_eq!(display_total, 2); + assert_eq!(3usize.saturating_sub(start_at) + 1, 1); // Review → 1/2 + assert_eq!(4usize.saturating_sub(start_at) + 1, 2); // Register → 2/2 + } + + #[test] + fn wizard_state_carries_all_fields_through_lifecycle() { + let mut state = WizardState::new() + .with_domain(Some("test.io".to_string())) + .with_period(3); + + state.available = true; + state.quote_token = Some("qt-123".to_string()); + state.price = Some("29.99".to_string()); + state.currency = Some("USD".to_string()); + state.agreement_titles = vec!["ICANN Registrant".to_string()]; + state.agreement_types = vec!["DNRA".to_string()]; + state.status = Some("COMPLETED".to_string()); + state.operation_id = Some("op-456".to_string()); + + assert_eq!(state.domain.as_deref(), Some("test.io")); + assert_eq!(state.period, 3); + assert!(state.available); + assert_eq!(state.quote_token.as_deref(), Some("qt-123")); + assert_eq!(state.price.as_deref(), Some("29.99")); + assert_eq!(state.currency.as_deref(), Some("USD")); + assert_eq!(state.agreement_titles.len(), 1); + assert_eq!(state.agreement_types.len(), 1); + assert_eq!(state.status.as_deref(), Some("COMPLETED")); + assert_eq!(state.operation_id.as_deref(), Some("op-456")); + } +} diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index b25465b2..4f6e174b 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -174,6 +174,19 @@ pub(super) fn command() -> RuntimeCommandSpec { }; let suggestions: Vec = resp.items.iter().filter_map(suggestion_to_json).collect(); + + // If interactive, offer to register one of the suggestions. + let domain_names: Vec = suggestions + .iter() + .filter_map(|s| s["domain"].as_str().map(str::to_owned)) + .collect(); + if let Some(wizard_result) = + super::register::bridge::offer_registration_from_suggest(&ctx, &domain_names) + .await? + { + return Ok(wizard_result); + } + Ok( CommandResult::new(json!(suggestions)).with_next_actions(vec![ next_action("domain available ", "Check a suggested domain") diff --git a/rust/src/main.rs b/rust/src/main.rs index aef05f4f..12397aaa 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -19,6 +19,7 @@ mod pat; mod payment_methods; mod platform; mod quote_cache; +mod retry; mod scopes; mod scopes_cmd; mod summary; @@ -84,6 +85,8 @@ async fn main() -> ExitCode { .with_auth_provider(auth_provider) .with_auth_extra_commands([scopes_cmd::auth_scopes_command()]) .with_min_stage(cli_engine::Stage::Ga) + // TODO: enable once all commands have been tested under interactive prompting + // .with_auto_interactive(true) .with_environments(Arc::clone(environments::instance())) .with_root_next_actions(Arc::new(|| { vec![ diff --git a/rust/src/retry.rs b/rust/src/retry.rs new file mode 100644 index 00000000..2d087de8 --- /dev/null +++ b/rust/src/retry.rs @@ -0,0 +1,120 @@ +//! Retry helper for transient network errors during API calls. + +use std::future::Future; +use std::time::Duration; + +use console::style; + +/// Retry an async operation up to `max_attempts` times with exponential backoff. +/// Only retries on errors that look transient (timeouts, 5xx, connection errors). +/// Prints a retry notice to stderr on each retry. +#[allow(clippy::print_stderr)] +pub(crate) async fn with_retry( + label: &str, + max_attempts: u32, + mut operation: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, + E: std::fmt::Display, +{ + let mut attempt = 0; + loop { + attempt += 1; + match operation().await { + Ok(v) => return Ok(v), + Err(e) if attempt < max_attempts && is_retryable(&e) => { + let delay = Duration::from_millis(1000 * 2u64.pow(attempt - 1)); + eprintln!( + " {} {} failed (attempt {}/{}), retrying in {}s...", + style("⟳").yellow(), + label, + attempt, + max_attempts, + delay.as_secs() + ); + tokio::time::sleep(delay).await; + } + Err(e) => return Err(e), + } + } +} + +/// Heuristic: is this error likely transient? +fn is_retryable(err: &E) -> bool { + let msg = err.to_string().to_lowercase(); + msg.contains("timeout") + || msg.contains("timed out") + || msg.contains("connection") + || msg.contains("503") + || msg.contains("502") + || msg.contains("504") + || msg.contains("service unavailable") + || msg.contains("temporarily") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + #[tokio::test] + async fn succeeds_on_first_attempt() { + let result: Result<&str, String> = with_retry("test", 3, || async { Ok("ok") }).await; + assert_eq!(result.expect("should succeed"), "ok"); + } + + #[tokio::test] + async fn retries_on_transient_error() { + let attempts = AtomicU32::new(0); + let result: Result<&str, String> = with_retry("test", 3, || { + let n = attempts.fetch_add(1, Ordering::SeqCst); + async move { + if n < 2 { + Err("connection timeout".to_owned()) + } else { + Ok("recovered") + } + } + }) + .await; + assert_eq!(result.expect("should recover"), "recovered"); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn does_not_retry_non_transient() { + let attempts = AtomicU32::new(0); + let result: Result<&str, String> = with_retry("test", 3, || { + attempts.fetch_add(1, Ordering::SeqCst); + async { Err("404 not found".to_owned()) } + }) + .await; + assert!(result.is_err()); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn exhausts_retries() { + let attempts = AtomicU32::new(0); + let result: Result<&str, String> = with_retry("test", 3, || { + attempts.fetch_add(1, Ordering::SeqCst); + async { Err("503 service unavailable".to_owned()) } + }) + .await; + assert!(result.is_err()); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } + + #[test] + fn is_retryable_detects_transient_errors() { + assert!(is_retryable(&"connection timeout")); + assert!(is_retryable(&"503 Service Unavailable")); + assert!(is_retryable(&"502 Bad Gateway")); + assert!(is_retryable(&"request timed out")); + assert!(!is_retryable(&"404 not found")); + assert!(!is_retryable(&"401 unauthorized")); + assert!(!is_retryable(&"invalid domain")); + } +}