From fc057903927d99329e037dbb6bceb25838216d5f Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 19 Aug 2026 08:45:06 -0400 Subject: [PATCH 01/24] initial commit Co-authored-by: Cursor --- .../INTERACTIVE_WIZARD_WORK_BREAKDOWN.md | 201 ++++++++++++++++ rust/Cargo.lock | 64 +++++ rust/Cargo.toml | 3 + rust/src/domain/mod.rs | 2 + rust/src/domain/register/mod.rs | 207 ++++++++++++++++ rust/src/domain/register/steps/discovery.rs | 209 +++++++++++++++++ rust/src/domain/register/steps/execute.rs | 220 ++++++++++++++++++ rust/src/domain/register/steps/mod.rs | 6 + rust/src/domain/register/steps/options.rs | 106 +++++++++ rust/src/domain/register/steps/review.rs | 179 ++++++++++++++ rust/src/domain/register/wizard.rs | 198 ++++++++++++++++ 11 files changed, 1395 insertions(+) create mode 100644 docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md create mode 100644 rust/src/domain/register/mod.rs create mode 100644 rust/src/domain/register/steps/discovery.rs create mode 100644 rust/src/domain/register/steps/execute.rs create mode 100644 rust/src/domain/register/steps/mod.rs create mode 100644 rust/src/domain/register/steps/options.rs create mode 100644 rust/src/domain/register/steps/review.rs create mode 100644 rust/src/domain/register/wizard.rs diff --git a/docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md b/docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md new file mode 100644 index 00000000..b774af56 --- /dev/null +++ b/docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md @@ -0,0 +1,201 @@ +# Interactive Domain Wizard — Work Breakdown + +Based on: +- [PR #193: Generalized Interactivity Design](https://github.com/godaddy/cli/pull/193) +- [INTERACTIVE_DOMAIN_WIZARD.md](./INTERACTIVE_DOMAIN_WIZARD.md) + +**Total Effort:** ~78 hours (~10 working days) +**Total PRs:** 6 (incremental, each independently mergeable) + +--- + +## Dependency Graph + +``` +PR 1: Interactivity Framework ──┐ + ▼ +PR 2: Wizard + Domain Register ──┬── PR 3: Contacts + Payment (parallel) + ├── PR 4: Add-On Products (parallel) + ├── PR 5: Multi-Entry Points (parallel) + │ + └── PR 6: Polish + Docs (after 2, enhanced by 3-5) +``` + +PRs 3, 4, and 5 can be developed in parallel once PR 2 merges. + +--- + +## PR 1: Generalized Interactivity Framework + +**Effort:** ~16h +**PR Title:** `feat: add generalized interactivity framework (--interactive flag + missing-input prompts)` +**Deliverable:** Any command with a missing required arg prompts for it when in interactive mode (TTY). Scripts/agents get the existing error behavior unchanged. + +### Tasks + +- [x] Add `inquire` dependency to cli-engine Cargo.toml +- [x] Add global `--interactive` / `--non-interactive` flag to cli-engine's root clap command +- [x] Implement TTY auto-detection: default `--interactive` when stderr is a TTY and `CI` env var is unset +- [x] Create `InteractivityMode` enum (Interactive, NonInteractive) and thread through MiddlewareSnapshot +- [x] Create `prompt` module in cli-engine with helpers: `prompt_text()`, `prompt_select()`, `prompt_confirm()`, `prompt_multi_select()` +- [x] Implement missing-input interception: when clap returns `MissingRequiredArgument` and mode is Interactive, iterate over missing args and prompt +- [x] Auto-detect prompt type from clap arg metadata: `possible_values` → Select, bool → Confirm, free text → Input +- [x] Respect arg declaration order for prompt sequence (documented convention) +- [x] On cancel mid-prompt: show resume command with already-supplied flags +- [x] **Unit test:** TTY detection returns correct mode for TTY/non-TTY/CI +- [x] **Unit test:** Prompt type inference from clap arg metadata (possible_values, bool, free text) +- [x] **Integration test:** Missing required arg + interactive mode → prompts (mocked stdin) +- [x] **Integration test:** Missing required arg + non-interactive mode → error with helpful message +- [x] **Integration test:** All args supplied + interactive mode → no prompts, executes directly +- [x] **Integration test:** Cancel mid-prompt → shows resume command +- [x] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` + +--- + +## PR 2: Wizard Step Framework + Domain Register Command + +**Effort:** ~24h +**PR Title:** `feat(domain): add interactive domain register wizard (discovery, options, quote, execute)` +**Deliverable:** Users can run `gddy domain register` and be walked through search → configure → confirm → buy in one session. Non-interactive fallback works with all flags. + +### Tasks + +- [x] Add `dialoguer`, `console`, `indicatif` to gddy Cargo.toml +- [x] Create `rust/src/domain/register/` directory structure: `mod.rs`, `wizard.rs`, `steps/{mod,discovery,options,review,execute}.rs` +- [x] Define `WizardState` struct (domain, available, period, privacy, auto_renew, nameservers, quote_token, price, etc.) +- [x] Define `StepResult` enum (Continue, Back, Cancel) and `WizardStep` trait +- [x] Implement `run_wizard()` step sequencer with forward/back navigation +- [x] Define `StepContext` (credential, env, debug, is_interactive, term) +- [x] Implement Discovery step: prompt for domain, call `/v3/domains/available`, show suggestions if taken, progressive pagination (5→15→25→50) +- [x] Implement Options step: period Select, privacy Confirm, auto-renew Confirm, custom NS Input +- [x] Implement Review step: call quote API, fetch agreements, display order summary, confirm prompt +- [x] Implement Execute step: call register API, show spinner, display success + next_actions +- [x] Create `RegisterArgs` struct with all CLI flags (`--period`, `--privacy`, `--agree`, `--confirm`, `--non-interactive`, etc.) +- [x] Implement TTY detection + non-interactive fallback (map flags → WizardState → execute directly) +- [x] Wire into domain group in `domain/mod.rs` +- [x] Implement human-friendly output for interactive mode (colored summary, not JSON) +- [ ] **Unit test:** `run_wizard` with mock steps (Continue, Back, Cancel navigation) +- [ ] **Unit test:** Domain name validation rejects invalid inputs +- [ ] **Unit test:** Suggestion deduplication + MAX_SUGGESTIONS cap +- [ ] **Integration test (mocked HTTP):** Non-interactive full flow → exit 0 with domain in result +- [ ] **Integration test (mocked HTTP):** Missing required flags in non-interactive → helpful error +- [ ] **Integration test (mocked HTTP):** `--dry-run` shows preview without charging +- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` + +--- + +## PR 3: Contacts Step + Payment Verification Gate + +**Effort:** ~10h +**PR Title:** `feat(domain): add contacts step + payment verification gate to domain register wizard` +**Deliverable:** Wizard loads contacts from `contacts.toml`, offers account default or manual entry with save-to-file, and verifies payment before executing. + +### Tasks + +- [ ] Implement Contacts step: check `contacts::load()`, offer reuse if exists +- [ ] Implement "Use account default" path (`state.contacts = None` → omit from request) +- [ ] Implement interactive contact collection: all required fields with validation +- [ ] Implement phone number validation using `phonenumber` crate +- [ ] Implement country code validation (two-letter ISO shape check) +- [ ] Implement `save_contact_to_file()` — write TOML to `~/.config/gddy/contacts.toml` +- [ ] Wire contacts into quote API request body +- [ ] Implement Payment Verification step (Step 5b): call Shoppers API `GET /v1/shoppers/{id}/paymentMethods` +- [ ] Implement fail-open on 401/403 (let purchase step catch real error) +- [ ] Implement browser-open flow for missing payment + re-verify loop +- [ ] Non-interactive mode: fail immediately with clear error if no payment method +- [ ] **Unit test:** Phone validation accepts various formats, rejects garbage +- [ ] **Unit test:** Country validation accepts US/GB, rejects USA/123 +- [ ] **Unit test:** `save_contact_to_file` roundtrip (write then `contacts::load()`) +- [ ] **Unit test:** Payment check 200+methods→true, 200+empty→false, 404→false, 401→true (fail-open) +- [ ] **Integration test (mocked HTTP):** Wizard with existing `contacts.toml` skips input +- [ ] **Integration test (mocked HTTP):** Payment method exists → continues to execution +- [ ] **Integration test (mocked HTTP):** Payment missing + non-interactive → error +- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` + +--- + +## PR 4: Add-On Products + Post-Registration Provisioning + +**Effort:** ~12h +**PR Title:** `feat(domain): add-on products (privacy, SSL, email) in domain register wizard` +**Deliverable:** Step 4 offers a multi-select of add-on products. After registration succeeds, selected add-ons are provisioned with per-item success/failure reporting. + +### Tasks + +- [ ] Define `AddOn` struct (id, name, price, description) and `AVAILABLE_ADDONS` catalog +- [ ] Implement Add-Ons step: MultiSelect with privacy pre-selected if Step 2 chose privacy +- [ ] Implement `provision_privacy()`: `POST /v1/domains/{domain}/purchase/privacy` with consent +- [ ] Implement `provision_ssl_certificate()`: `POST /v1/certificates` with DV_SSL type +- [ ] Implement `provision_email()`: `POST /v1/email/domains/{domain}` +- [ ] Implement `execute_addons()` orchestrator: iterate, spinner per add-on, collect results +- [ ] Add-on failures don't fail the overall command (domain already registered) +- [ ] Add `--add ` repeatable flag for non-interactive mode +- [ ] Include add-on results in final JSON output (success/failure per product) +- [ ] **Unit test:** Empty selection → `state.addons` is empty +- [ ] **Unit test:** Privacy pre-selection logic based on `state.privacy` +- [ ] **Integration test (mocked HTTP):** 2 add-ons selected, one succeeds one fails → mixed results, exit 0 +- [ ] **Integration test:** `--add privacy --add ssl` maps to `state.addons` correctly +- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` + +--- + +## PR 5: Multi-Entry Points (`--interactive` on suggest/available/quote) + +**Effort:** ~8h +**PR Title:** `feat(domain): multi-entry wizard (--interactive on suggest, available, quote)` +**Deliverable:** `gddy domain suggest 'cool name' --interactive` fetches suggestions then enters the wizard. Same for `available` and `quote`. + +### Tasks + +- [ ] Add `--interactive` flag to `domain suggest` command +- [ ] After suggest results: inject into WizardState, call `run_wizard(start_at=0)` for pick-from-results +- [ ] Add `--interactive` flag to `domain available` command +- [ ] If available: inject domain, call `run_wizard(start_at=1)` for Options step +- [ ] If taken: inject suggestions, call `run_wizard(start_at=0)` for pick +- [ ] Add `--interactive` flag to `domain quote` command +- [ ] Inject quote token + price, call `run_wizard(start_at=4)` for Review step +- [ ] Add `start_at` parameter to `run_wizard()` to skip earlier steps +- [ ] **Integration test:** `domain available test.com --interactive` enters wizard at step 2 +- [ ] **Integration test:** `domain suggest 'test' --interactive` enters wizard at step 1 +- [ ] **Integration test:** `--interactive` without TTY → ignores flag, normal output +- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` + +--- + +## PR 6: Polish — Error Recovery, Progress, Documentation + +**Effort:** ~8h +**PR Title:** `feat(domain): wizard polish — error recovery, progress indicators, guide` +**Deliverable:** Production-quality wizard with retry on network errors, step counter display, clean Ctrl+C exit, and `gddy guide domain-register`. + +### Tasks + +- [ ] Add step counter header to each step: "Step N of 6: \" +- [ ] Implement network retry with exponential backoff (3 attempts) for API calls +- [ ] Implement quote auto-refresh: if `quote_expires_at < now` before execution, re-quote +- [ ] Implement Ctrl+C handling: clean exit, no state persisted, no charge +- [ ] Add "Go back" option to Select prompts in Steps 2-5 +- [ ] Create `gddy guide domain-register` markdown guide +- [ ] Update domain group long description to mention `register` +- [ ] Add `--interactive` flag documentation to suggest/available/quote help text +- [ ] **Unit test:** Retry logic (first fails, second succeeds → success) +- [ ] **Unit test:** Retry logic (all 3 fail → error) +- [ ] **Unit test:** Quote expiry detection and auto-refresh trigger +- [ ] **Manual test:** Full end-to-end in OTE environment +- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` + +--- + +## Manual Testing Checklist (end-to-end after all PRs merged) + +| Scenario | Command | Expected | +|----------|---------|----------| +| Full wizard happy path | `gddy domain register` | Walk through all 6 steps, domain registered | +| Non-interactive with all flags | `gddy domain register example.com --period 1 --privacy --agree --confirm --non-interactive` | Registers without prompts | +| Entry from suggest | `gddy domain suggest "cool startup" --interactive` | Shows suggestions → enters wizard | +| Entry from available (taken) | `gddy domain available taken.com --interactive` | Shows alternatives → enters wizard | +| Missing flag non-interactive | `gddy domain register --non-interactive` | Error with guidance | +| Ctrl+C at any step | Ctrl+C during wizard | Clean exit, no side effects | +| No payment method | (remove payment) `gddy domain register` | Catches at Step 5b, opens browser | +| Piped input (no TTY) | `echo "test.com" \| gddy domain register` | Non-interactive error | +| JSON output override | `gddy domain register --output json` | JSON envelope output | diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 15cca1c1..a58c3f6f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -643,6 +643,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 +914,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 +1034,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 +1377,15 @@ dependencies = [ "chrono", "clap", "cli-engine", + "console", + "dialoguer", "dirs", "domains-client", "fancy-regex", "flate2", "globset", "httpmock", + "indicatif", "iso_currency", "open", "oxc_allocator", @@ -1782,6 +1815,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 +2749,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 +3690,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 +4399,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/domain/mod.rs b/rust/src/domain/mod.rs index fdf21621..46faf231 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 @@ -63,6 +64,7 @@ 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()) diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs new file mode 100644 index 00000000..bebae6bd --- /dev/null +++ b/rust/src/domain/register/mod.rs @@ -0,0 +1,207 @@ +//! `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, CommandResult, CommandSpec, NextActionParam, Result, RuntimeCommandSpec, Tier, +}; +use serde_json::json; + +use crate::domain::common::validate_domain_name; +use crate::next_action::next_action; +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 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; +}); + +#[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_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: cli_engine::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?; + + build_result(&final_state) +} + +async fn run_non_interactive( + ctx: cli_engine::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 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); + + let step_ctx = StepContext { + credential: cred, + env, + debug, + }; + + // In non-interactive mode, we skip the wizard UI and execute the steps + // directly (availability check → quote → register), relying on flags for + // all configuration. + let final_state = wizard::run_wizard(state, step_ctx, 0).await?; + + build_result(&final_state) +} + +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 actions = vec![ + next_action("domain get ", "See the registered domain's details") + .with_param("domain", NextActionParam::required()), + ]; + + Ok(CommandResult::new(result).with_next_actions(actions)) +} diff --git a/rust/src/domain/register/steps/discovery.rs b/rust/src/domain/register/steps/discovery.rs new file mode 100644 index 00000000..a0ba0475 --- /dev/null +++ b/rust/src/domain/register/steps/discovery.rs @@ -0,0 +1,209 @@ +//! 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 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. + let availability = match client + .get_domain_availability() + .domain(domain.as_str()) + .send() + .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("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 client + .suggest_domains() + .query(domain) + .page_size(page_size) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain suggestion", debug, e).await), + }; + + let mut all: Vec = Vec::new(); + for item in &resp.items { + if all.len() >= MAX_SUGGESTIONS { + 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, + }); + } + + Ok(all) +} + +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, +} diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs new file mode 100644 index 00000000..218ac0f0 --- /dev/null +++ b/rust/src/domain/register/steps/execute.rs @@ -0,0 +1,220 @@ +//! Step 4: 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 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 = uuid::Uuid::new_v4().to_string(); + let accepted = match client + .register_domain() + .idempotency_key(idempotency_key) + .body(registration) + .send() + .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)); + 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, + } + } + } + + 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 { + 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); + } + eprintln!("\n Next steps:"); + eprintln!(" • gddy domain get {domain}"); + eprintln!(" • gddy dns set {domain} --type A --name @ --data "); + + 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..4b7103c2 --- /dev/null +++ b/rust/src/domain/register/steps/mod.rs @@ -0,0 +1,6 @@ +//! Wizard step implementations for the domain registration flow. + +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..fc3f4ba3 --- /dev/null +++ b/rust/src/domain/register/steps/options.rs @@ -0,0 +1,106 @@ +//! 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" }, + ); + + 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!(" 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..24ef5456 --- /dev/null +++ b/rust/src/domain/register/steps/review.rs @@ -0,0 +1,179 @@ +//! Step 3: Review — fetch a quote, display agreements, show order summary, and +//! request confirmation before executing. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use dialoguer::Confirm; + +use domains_client::types; + +use crate::domain::common::{ + api_error, format_money, make_client_with_cred, period_label, validate_nameserver_hosts, +}; +use crate::quote_cache; + +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(); + + 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(), + )) + }; + + let profile = types::InlineRegistrationProfile { + auto_renew: Some(state.auto_renew), + contacts: None, + 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 = match client + .quote_domain_registration() + .body(types::QuoteDomainRegistrationBody { + domain: domain.clone(), + period: period_nz, + profile: Some(profile), + profile_id: None, + }) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain quote", debug, e).await), + }; + + // 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. + eprintln!("\n ┌─────────────────────────────────────"); + eprintln!(" │ {} Order Summary", style("📋").bold()); + eprintln!(" ├─────────────────────────────────────"); + eprintln!(" │ Domain: {}", style(&domain).cyan().bold()); + eprintln!(" │ Period: {}", period_label(state.period)); + if let Some(p) = &price_str { + eprintln!(" │ Price: {} {}", style(p).green().bold(), currency); + } + if let Some(r) = &renewal_str { + eprintln!(" │ Renewal: {} {}/yr", r, currency); + } + eprintln!(" │ Privacy: {}", if state.privacy { "Yes" } else { "No" }); + eprintln!( + " │ Auto-renew: {}", + if state.auto_renew { "Yes" } else { "No" } + ); + if !state.nameservers.is_empty() { + eprintln!(" │ Nameservers: {}", state.nameservers.join(", ")); + } + eprintln!(" └─────────────────────────────────────"); + + // Show agreements. + if !agreement_titles.is_empty() { + eprintln!("\n Legal agreements:"); + for title in &agreement_titles { + eprintln!(" • {title}"); + } + } + + // Confirm. + let confirmed = Confirm::new() + .with_prompt(format!( + "Proceed with registration? This will charge {} {} to your account", + price_str.as_deref().unwrap_or("the quoted price"), + currency + )) + .default(false) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if !confirmed { + return Ok(StepResult::Cancel); + } + + // 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 = quote + .fees + .as_ref() + .and_then(|f| serde_json::to_value(f).ok()); + 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) +} diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs new file mode 100644 index 00000000..90f96698 --- /dev/null +++ b/rust/src/domain/register/wizard.rs @@ -0,0 +1,198 @@ +//! 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: Review (populated after quote) + pub quote_token: Option, + pub price: Option, + pub currency: Option, + pub agreement_titles: Vec, + pub agreement_types: Vec, + + // Step 4: Execute (populated after registration) + pub status: Option, + pub operation_id: Option, +} + +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: "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. +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; + + loop { + if current >= total_steps { + break; + } + + let step = &STEPS[current]; + eprintln!( + "\n {} Step {}/{}: {}", + style("─").dim(), + current + 1, + total_steps, + style(step.name).bold() + ); + + let result = match current { + 0 => steps::discovery::run(&mut state, &ctx).await?, + 1 => steps::options::run(&mut state, &ctx).await?, + 2 => steps::review::run(&mut state, &ctx).await?, + 3 => steps::execute::run(&mut state, &ctx).await?, + _ => unreachable!(), + }; + + match result { + StepResult::Continue => { + current += 1; + } + StepResult::Back => { + if current > start_at { + current -= 1; + } + // If already at start, the step will re-run (loop continues). + } + StepResult::Cancel => { + eprintln!( + "\n {} Wizard cancelled. No charges were made.", + style("✗").red().bold() + ); + return Err(cli_engine::CliCoreError::message( + "domain registration cancelled by user", + )); + } + } + } + + Ok(state) +} + +#[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); + } +} From de9b8e624984e1780898197d62dcc86756755bf5 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Mon, 24 Aug 2026 17:00:23 -0400 Subject: [PATCH 02/24] feat(domain): add interactive domain registration wizard --- .gitignore | 1 + .../INTERACTIVE_WIZARD_WORK_BREAKDOWN.md | 201 ------------------ rust/src/config/settings_form.rs | 8 +- rust/src/domain/register/mod.rs | 132 ++++++++++++ rust/src/domain/register/steps/discovery.rs | 126 ++++++++--- rust/src/domain/register/steps/review.rs | 62 +++--- rust/src/domain/register/wizard.rs | 36 ++++ rust/src/main.rs | 2 + 8 files changed, 310 insertions(+), 258 deletions(-) delete mode 100644 docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md 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/docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md b/docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md deleted file mode 100644 index b774af56..00000000 --- a/docs/design/INTERACTIVE_WIZARD_WORK_BREAKDOWN.md +++ /dev/null @@ -1,201 +0,0 @@ -# Interactive Domain Wizard — Work Breakdown - -Based on: -- [PR #193: Generalized Interactivity Design](https://github.com/godaddy/cli/pull/193) -- [INTERACTIVE_DOMAIN_WIZARD.md](./INTERACTIVE_DOMAIN_WIZARD.md) - -**Total Effort:** ~78 hours (~10 working days) -**Total PRs:** 6 (incremental, each independently mergeable) - ---- - -## Dependency Graph - -``` -PR 1: Interactivity Framework ──┐ - ▼ -PR 2: Wizard + Domain Register ──┬── PR 3: Contacts + Payment (parallel) - ├── PR 4: Add-On Products (parallel) - ├── PR 5: Multi-Entry Points (parallel) - │ - └── PR 6: Polish + Docs (after 2, enhanced by 3-5) -``` - -PRs 3, 4, and 5 can be developed in parallel once PR 2 merges. - ---- - -## PR 1: Generalized Interactivity Framework - -**Effort:** ~16h -**PR Title:** `feat: add generalized interactivity framework (--interactive flag + missing-input prompts)` -**Deliverable:** Any command with a missing required arg prompts for it when in interactive mode (TTY). Scripts/agents get the existing error behavior unchanged. - -### Tasks - -- [x] Add `inquire` dependency to cli-engine Cargo.toml -- [x] Add global `--interactive` / `--non-interactive` flag to cli-engine's root clap command -- [x] Implement TTY auto-detection: default `--interactive` when stderr is a TTY and `CI` env var is unset -- [x] Create `InteractivityMode` enum (Interactive, NonInteractive) and thread through MiddlewareSnapshot -- [x] Create `prompt` module in cli-engine with helpers: `prompt_text()`, `prompt_select()`, `prompt_confirm()`, `prompt_multi_select()` -- [x] Implement missing-input interception: when clap returns `MissingRequiredArgument` and mode is Interactive, iterate over missing args and prompt -- [x] Auto-detect prompt type from clap arg metadata: `possible_values` → Select, bool → Confirm, free text → Input -- [x] Respect arg declaration order for prompt sequence (documented convention) -- [x] On cancel mid-prompt: show resume command with already-supplied flags -- [x] **Unit test:** TTY detection returns correct mode for TTY/non-TTY/CI -- [x] **Unit test:** Prompt type inference from clap arg metadata (possible_values, bool, free text) -- [x] **Integration test:** Missing required arg + interactive mode → prompts (mocked stdin) -- [x] **Integration test:** Missing required arg + non-interactive mode → error with helpful message -- [x] **Integration test:** All args supplied + interactive mode → no prompts, executes directly -- [x] **Integration test:** Cancel mid-prompt → shows resume command -- [x] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` - ---- - -## PR 2: Wizard Step Framework + Domain Register Command - -**Effort:** ~24h -**PR Title:** `feat(domain): add interactive domain register wizard (discovery, options, quote, execute)` -**Deliverable:** Users can run `gddy domain register` and be walked through search → configure → confirm → buy in one session. Non-interactive fallback works with all flags. - -### Tasks - -- [x] Add `dialoguer`, `console`, `indicatif` to gddy Cargo.toml -- [x] Create `rust/src/domain/register/` directory structure: `mod.rs`, `wizard.rs`, `steps/{mod,discovery,options,review,execute}.rs` -- [x] Define `WizardState` struct (domain, available, period, privacy, auto_renew, nameservers, quote_token, price, etc.) -- [x] Define `StepResult` enum (Continue, Back, Cancel) and `WizardStep` trait -- [x] Implement `run_wizard()` step sequencer with forward/back navigation -- [x] Define `StepContext` (credential, env, debug, is_interactive, term) -- [x] Implement Discovery step: prompt for domain, call `/v3/domains/available`, show suggestions if taken, progressive pagination (5→15→25→50) -- [x] Implement Options step: period Select, privacy Confirm, auto-renew Confirm, custom NS Input -- [x] Implement Review step: call quote API, fetch agreements, display order summary, confirm prompt -- [x] Implement Execute step: call register API, show spinner, display success + next_actions -- [x] Create `RegisterArgs` struct with all CLI flags (`--period`, `--privacy`, `--agree`, `--confirm`, `--non-interactive`, etc.) -- [x] Implement TTY detection + non-interactive fallback (map flags → WizardState → execute directly) -- [x] Wire into domain group in `domain/mod.rs` -- [x] Implement human-friendly output for interactive mode (colored summary, not JSON) -- [ ] **Unit test:** `run_wizard` with mock steps (Continue, Back, Cancel navigation) -- [ ] **Unit test:** Domain name validation rejects invalid inputs -- [ ] **Unit test:** Suggestion deduplication + MAX_SUGGESTIONS cap -- [ ] **Integration test (mocked HTTP):** Non-interactive full flow → exit 0 with domain in result -- [ ] **Integration test (mocked HTTP):** Missing required flags in non-interactive → helpful error -- [ ] **Integration test (mocked HTTP):** `--dry-run` shows preview without charging -- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` - ---- - -## PR 3: Contacts Step + Payment Verification Gate - -**Effort:** ~10h -**PR Title:** `feat(domain): add contacts step + payment verification gate to domain register wizard` -**Deliverable:** Wizard loads contacts from `contacts.toml`, offers account default or manual entry with save-to-file, and verifies payment before executing. - -### Tasks - -- [ ] Implement Contacts step: check `contacts::load()`, offer reuse if exists -- [ ] Implement "Use account default" path (`state.contacts = None` → omit from request) -- [ ] Implement interactive contact collection: all required fields with validation -- [ ] Implement phone number validation using `phonenumber` crate -- [ ] Implement country code validation (two-letter ISO shape check) -- [ ] Implement `save_contact_to_file()` — write TOML to `~/.config/gddy/contacts.toml` -- [ ] Wire contacts into quote API request body -- [ ] Implement Payment Verification step (Step 5b): call Shoppers API `GET /v1/shoppers/{id}/paymentMethods` -- [ ] Implement fail-open on 401/403 (let purchase step catch real error) -- [ ] Implement browser-open flow for missing payment + re-verify loop -- [ ] Non-interactive mode: fail immediately with clear error if no payment method -- [ ] **Unit test:** Phone validation accepts various formats, rejects garbage -- [ ] **Unit test:** Country validation accepts US/GB, rejects USA/123 -- [ ] **Unit test:** `save_contact_to_file` roundtrip (write then `contacts::load()`) -- [ ] **Unit test:** Payment check 200+methods→true, 200+empty→false, 404→false, 401→true (fail-open) -- [ ] **Integration test (mocked HTTP):** Wizard with existing `contacts.toml` skips input -- [ ] **Integration test (mocked HTTP):** Payment method exists → continues to execution -- [ ] **Integration test (mocked HTTP):** Payment missing + non-interactive → error -- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` - ---- - -## PR 4: Add-On Products + Post-Registration Provisioning - -**Effort:** ~12h -**PR Title:** `feat(domain): add-on products (privacy, SSL, email) in domain register wizard` -**Deliverable:** Step 4 offers a multi-select of add-on products. After registration succeeds, selected add-ons are provisioned with per-item success/failure reporting. - -### Tasks - -- [ ] Define `AddOn` struct (id, name, price, description) and `AVAILABLE_ADDONS` catalog -- [ ] Implement Add-Ons step: MultiSelect with privacy pre-selected if Step 2 chose privacy -- [ ] Implement `provision_privacy()`: `POST /v1/domains/{domain}/purchase/privacy` with consent -- [ ] Implement `provision_ssl_certificate()`: `POST /v1/certificates` with DV_SSL type -- [ ] Implement `provision_email()`: `POST /v1/email/domains/{domain}` -- [ ] Implement `execute_addons()` orchestrator: iterate, spinner per add-on, collect results -- [ ] Add-on failures don't fail the overall command (domain already registered) -- [ ] Add `--add ` repeatable flag for non-interactive mode -- [ ] Include add-on results in final JSON output (success/failure per product) -- [ ] **Unit test:** Empty selection → `state.addons` is empty -- [ ] **Unit test:** Privacy pre-selection logic based on `state.privacy` -- [ ] **Integration test (mocked HTTP):** 2 add-ons selected, one succeeds one fails → mixed results, exit 0 -- [ ] **Integration test:** `--add privacy --add ssl` maps to `state.addons` correctly -- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` - ---- - -## PR 5: Multi-Entry Points (`--interactive` on suggest/available/quote) - -**Effort:** ~8h -**PR Title:** `feat(domain): multi-entry wizard (--interactive on suggest, available, quote)` -**Deliverable:** `gddy domain suggest 'cool name' --interactive` fetches suggestions then enters the wizard. Same for `available` and `quote`. - -### Tasks - -- [ ] Add `--interactive` flag to `domain suggest` command -- [ ] After suggest results: inject into WizardState, call `run_wizard(start_at=0)` for pick-from-results -- [ ] Add `--interactive` flag to `domain available` command -- [ ] If available: inject domain, call `run_wizard(start_at=1)` for Options step -- [ ] If taken: inject suggestions, call `run_wizard(start_at=0)` for pick -- [ ] Add `--interactive` flag to `domain quote` command -- [ ] Inject quote token + price, call `run_wizard(start_at=4)` for Review step -- [ ] Add `start_at` parameter to `run_wizard()` to skip earlier steps -- [ ] **Integration test:** `domain available test.com --interactive` enters wizard at step 2 -- [ ] **Integration test:** `domain suggest 'test' --interactive` enters wizard at step 1 -- [ ] **Integration test:** `--interactive` without TTY → ignores flag, normal output -- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` - ---- - -## PR 6: Polish — Error Recovery, Progress, Documentation - -**Effort:** ~8h -**PR Title:** `feat(domain): wizard polish — error recovery, progress indicators, guide` -**Deliverable:** Production-quality wizard with retry on network errors, step counter display, clean Ctrl+C exit, and `gddy guide domain-register`. - -### Tasks - -- [ ] Add step counter header to each step: "Step N of 6: \" -- [ ] Implement network retry with exponential backoff (3 attempts) for API calls -- [ ] Implement quote auto-refresh: if `quote_expires_at < now` before execution, re-quote -- [ ] Implement Ctrl+C handling: clean exit, no state persisted, no charge -- [ ] Add "Go back" option to Select prompts in Steps 2-5 -- [ ] Create `gddy guide domain-register` markdown guide -- [ ] Update domain group long description to mention `register` -- [ ] Add `--interactive` flag documentation to suggest/available/quote help text -- [ ] **Unit test:** Retry logic (first fails, second succeeds → success) -- [ ] **Unit test:** Retry logic (all 3 fail → error) -- [ ] **Unit test:** Quote expiry detection and auto-refresh trigger -- [ ] **Manual test:** Full end-to-end in OTE environment -- [ ] `cargo fmt --check && cargo clippy -- -D warnings && cargo test` - ---- - -## Manual Testing Checklist (end-to-end after all PRs merged) - -| Scenario | Command | Expected | -|----------|---------|----------| -| Full wizard happy path | `gddy domain register` | Walk through all 6 steps, domain registered | -| Non-interactive with all flags | `gddy domain register example.com --period 1 --privacy --agree --confirm --non-interactive` | Registers without prompts | -| Entry from suggest | `gddy domain suggest "cool startup" --interactive` | Shows suggestions → enters wizard | -| Entry from available (taken) | `gddy domain available taken.com --interactive` | Shows alternatives → enters wizard | -| Missing flag non-interactive | `gddy domain register --non-interactive` | Error with guidance | -| Ctrl+C at any step | Ctrl+C during wizard | Clean exit, no side effects | -| No payment method | (remove payment) `gddy domain register` | Catches at Step 5b, opens browser | -| Piped input (no TTY) | `echo "test.com" \| gddy domain register` | Non-interactive error | -| JSON output override | `gddy domain register --output json` | JSON envelope output | 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/register/mod.rs b/rust/src/domain/register/mod.rs index bebae6bd..c937ba6f 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -205,3 +205,135 @@ fn build_result(state: &WizardState) -> Result { Ok(CommandResult::new(result).with_next_actions(actions)) } + +#[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 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/discovery.rs b/rust/src/domain/register/steps/discovery.rs index a0ba0475..a1a724cd 100644 --- a/rust/src/domain/register/steps/discovery.rs +++ b/rust/src/domain/register/steps/discovery.rs @@ -111,33 +111,7 @@ async fn fetch_suggestions( Err(e) => return Err(api_error("domain suggestion", debug, e).await), }; - let mut all: Vec = Vec::new(); - for item in &resp.items { - if all.len() >= MAX_SUGGESTIONS { - 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, - }); - } - - Ok(all) + Ok(collect_suggestions(&resp.items, MAX_SUGGESTIONS)) } fn select_from_suggestions( @@ -207,3 +181,101 @@ struct SuggestionEntry { 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/review.rs b/rust/src/domain/register/steps/review.rs index 24ef5456..e3431d9e 100644 --- a/rust/src/domain/register/steps/review.rs +++ b/rust/src/domain/register/steps/review.rs @@ -3,7 +3,7 @@ use cli_engine::{CliCoreError, Result}; use console::style; -use dialoguer::Confirm; +use dialoguer::Select; use domains_client::types; @@ -98,26 +98,24 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result return Ok(StepResult::Back), + 2 => return Ok(StepResult::Cancel), + _ => {} // 0 = proceed } // Cache the quote for the execute step. @@ -149,10 +155,14 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result 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 { diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs index 90f96698..ffed1745 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -195,4 +195,40 @@ mod tests { assert_eq!(StepResult::Cancel, StepResult::Cancel); assert_ne!(StepResult::Continue, StepResult::Cancel); } + + #[test] + fn steps_metadata_has_expected_count() { + assert_eq!(STEPS.len(), 4); + assert_eq!(STEPS[0].name, "Discovery"); + assert_eq!(STEPS[1].name, "Options"); + assert_eq!(STEPS[2].name, "Review & Confirm"); + assert_eq!(STEPS[3].name, "Register"); + } + + #[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/main.rs b/rust/src/main.rs index aef05f4f..5db67866 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -84,6 +84,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![ From 232323d92755e568908e3671aa33b7a0696152c0 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 25 Aug 2026 10:04:38 -0400 Subject: [PATCH 03/24] feat(domain): complete interactive registration wizard Co-authored-by: Cursor --- rust/src/domain/available.rs | 23 +- rust/src/domain/guides/domain-register.md | 94 +++++ rust/src/domain/mod.rs | 22 +- rust/src/domain/quote.rs | 23 ++ rust/src/domain/register/bridge.rs | 122 ++++++ rust/src/domain/register/mod.rs | 25 ++ rust/src/domain/register/retry.rs | 120 ++++++ rust/src/domain/register/steps/contacts.rs | 428 ++++++++++++++++++++ rust/src/domain/register/steps/discovery.rs | 32 +- rust/src/domain/register/steps/execute.rs | 40 +- rust/src/domain/register/steps/mod.rs | 1 + rust/src/domain/register/steps/options.rs | 11 + rust/src/domain/register/steps/review.rs | 176 ++++++-- rust/src/domain/register/wizard.rs | 58 ++- rust/src/domain/suggest.rs | 13 + 15 files changed, 1125 insertions(+), 63 deletions(-) create mode 100644 rust/src/domain/guides/domain-register.md create mode 100644 rust/src/domain/register/bridge.rs create mode 100644 rust/src/domain/register/retry.rs create mode 100644 rust/src/domain/register/steps/contacts.rs 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 46faf231..7ea28fd7 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -47,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`.", ), ) @@ -69,10 +69,16 @@ pub fn module() -> Module { .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..4a10f263 --- /dev/null +++ b/rust/src/domain/register/bridge.rs @@ -0,0 +1,122 @@ +//! Bridge functions allowing other domain commands (suggest, available, quote) +//! to hand off to the registration wizard when running interactively. + +use cli_engine::{CommandResult, Result}; +use dialoguer::Confirm; + +use super::wizard::WizardState; + +/// After `domain available` finds a domain is available, offer to continue +/// with registration. Returns `None` if the user declines. +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); + } + + let proceed = Confirm::new() + .with_prompt(format!("Would you like to register {domain}?")) + .default(false) + .interact() + .unwrap_or(false); + + if !proceed { + return Ok(None); + } + + let mut state = WizardState::new().with_domain(Some(domain.to_owned())); + state.available = true; + state.price = price; + state.currency = currency; + + let result = super::launch_wizard(ctx, state, 1).await?; + Ok(Some(result)) +} + +/// After `domain suggest` displays results, offer to pick one and register. +/// Returns `None` if the user declines. +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); + } + + let proceed = Confirm::new() + .with_prompt("Would you like to register one of these domains?") + .default(false) + .interact() + .unwrap_or(false); + + if !proceed { + return Ok(None); + } + + let mut items: Vec = suggestions.to_vec(); + items.push("(enter a different domain)".to_owned()); + + let selection = dialoguer::Select::new() + .with_prompt("Select a domain") + .items(&items) + .default(0) + .interact() + .unwrap_or(items.len() - 1); + + let domain = if selection == items.len() - 1 { + None + } else { + Some(items[selection].clone()) + }; + + let mut state = WizardState::new().with_domain(domain.clone()); + if domain.is_some() { + state.available = true; + let result = super::launch_wizard(ctx, state, 1).await?; + Ok(Some(result)) + } else { + let result = super::launch_wizard(ctx, state, 0).await?; + Ok(Some(result)) + } +} + +/// After `domain quote` prices a domain, offer to purchase it directly. +/// Returns `None` if the user declines. +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); + } + + let proceed = Confirm::new() + .with_prompt(format!("Would you like to purchase {domain} now?")) + .default(false) + .interact() + .unwrap_or(false); + + 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; + state.currency = currency; + + // Start at step 3 (Review & Confirm) since quote is already done. + let result = super::launch_wizard(ctx, state, 3).await?; + Ok(Some(result)) +} diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs index c937ba6f..c5be1da3 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -17,6 +17,10 @@ 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 retry; +#[allow(clippy::print_stderr)] pub(crate) mod steps; #[allow(clippy::print_stderr)] pub(crate) mod wizard; @@ -177,6 +181,27 @@ async fn run_non_interactive( build_result(&final_state) } +/// 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: &cli_engine::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?; + build_result(&final_state) +} + fn build_result(state: &WizardState) -> Result { let domain = state .domain diff --git a/rust/src/domain/register/retry.rs b/rust/src/domain/register/retry.rs new file mode 100644 index 00000000..19fc65d2 --- /dev/null +++ b/rust/src/domain/register/retry.rs @@ -0,0 +1,120 @@ +//! Retry helper for transient network errors during wizard 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. +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: std::result::Result<&str, String> = + with_retry("test", 3, || async { Ok("ok") }).await; + assert_eq!(result.unwrap(), "ok"); + } + + #[tokio::test] + async fn retries_on_transient_error() { + let attempts = AtomicU32::new(0); + let result: std::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.unwrap(), "recovered"); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn does_not_retry_non_transient() { + let attempts = AtomicU32::new(0); + let result: std::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: std::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")); + } +} diff --git a/rust/src/domain/register/steps/contacts.rs b/rust/src/domain/register/steps/contacts.rs new file mode 100644 index 00000000..5d6d3cf9 --- /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!(" {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!(" {label}")) + .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!(" {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 index a1a724cd..cfc71390 100644 --- a/rust/src/domain/register/steps/discovery.rs +++ b/rust/src/domain/register/steps/discovery.rs @@ -9,6 +9,7 @@ use crate::domain::common::{ api_error, make_client_with_cred, term_for_period, validate_domain_name, }; +use super::super::retry::with_retry; use super::super::wizard::{StepContext, StepResult, WizardState}; /// Maximum suggestions to show when a domain is taken. @@ -28,12 +29,13 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result r.into_inner(), Err(e) => return Err(api_error("domain availability check", debug, e).await), @@ -100,12 +102,18 @@ async fn fetch_suggestions( ) -> Result> { let page_size = std::num::NonZeroI64::new(MAX_SUGGESTIONS as i64).expect("MAX_SUGGESTIONS is non-zero"); - let resp = match client - .suggest_domains() - .query(domain) - .page_size(page_size) - .send() - .await + 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), diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs index 218ac0f0..6e58c5d1 100644 --- a/rust/src/domain/register/steps/execute.rs +++ b/rust/src/domain/register/steps/execute.rs @@ -12,6 +12,7 @@ use crate::domain::common::{ }; use crate::quote_cache; +use super::super::retry::with_retry; use super::super::wizard::{StepContext, StepResult, WizardState}; pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { @@ -122,12 +123,19 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result r.into_inner(), Err(e) => { @@ -150,6 +158,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result 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(); @@ -190,6 +215,8 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result"); diff --git a/rust/src/domain/register/steps/mod.rs b/rust/src/domain/register/steps/mod.rs index 4b7103c2..87469fe4 100644 --- a/rust/src/domain/register/steps/mod.rs +++ b/rust/src/domain/register/steps/mod.rs @@ -1,5 +1,6 @@ //! Wizard step implementations for the domain registration flow. +pub(super) mod contacts; pub(super) mod discovery; pub(super) mod execute; pub(super) mod options; diff --git a/rust/src/domain/register/steps/options.rs b/rust/src/domain/register/steps/options.rs index fc3f4ba3..33a908dd 100644 --- a/rust/src/domain/register/steps/options.rs +++ b/rust/src/domain/register/steps/options.rs @@ -72,6 +72,17 @@ pub(crate) async fn run(state: &mut WizardState, _ctx: &StepContext) -> Result Result { let domain = state @@ -46,9 +50,12 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result r.into_inner(), - Err(e) => return Err(api_error("domain quote", debug, e).await), + 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. @@ -100,20 +115,55 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result 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), + } +} diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs index ffed1745..f9c381d2 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -37,18 +37,33 @@ pub(crate) struct WizardState { pub auto_renew: bool, pub nameservers: Vec, - // Step 3: Review (populated after quote) + // 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 4: Execute (populated after registration) + // Step 5: Execute (populated after registration) pub status: Option, pub operation_id: Option, } +/// 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 { @@ -94,6 +109,7 @@ struct StepInfo { const STEPS: &[StepInfo] = &[ StepInfo { name: "Discovery" }, StepInfo { name: "Options" }, + StepInfo { name: "Contacts" }, StepInfo { name: "Review & Confirm", }, @@ -126,14 +142,29 @@ pub(crate) async fn run_wizard( style(step.name).bold() ); - let result = match current { - 0 => steps::discovery::run(&mut state, &ctx).await?, - 1 => steps::options::run(&mut state, &ctx).await?, - 2 => steps::review::run(&mut state, &ctx).await?, - 3 => steps::execute::run(&mut state, &ctx).await?, + 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() + ); + return Err(cli_engine::CliCoreError::message( + "domain registration interrupted by user", + )); + } + Err(e) => return Err(e), + }; + match result { StepResult::Continue => { current += 1; @@ -159,6 +190,12 @@ pub(crate) async fn run_wizard( 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::*; @@ -198,11 +235,12 @@ mod tests { #[test] fn steps_metadata_has_expected_count() { - assert_eq!(STEPS.len(), 4); + assert_eq!(STEPS.len(), 5); assert_eq!(STEPS[0].name, "Discovery"); assert_eq!(STEPS[1].name, "Options"); - assert_eq!(STEPS[2].name, "Review & Confirm"); - assert_eq!(STEPS[3].name, "Register"); + assert_eq!(STEPS[2].name, "Contacts"); + assert_eq!(STEPS[3].name, "Review & Confirm"); + assert_eq!(STEPS[4].name, "Register"); } #[test] 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") From d6dda4c6b1fd5cafe938056854e926aaa8b0da02 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 25 Aug 2026 14:25:29 -0400 Subject: [PATCH 04/24] refactored retry --- rust/src/domain/register/mod.rs | 2 -- rust/src/domain/register/steps/discovery.rs | 3 ++- rust/src/domain/register/steps/execute.rs | 3 ++- rust/src/domain/register/steps/review.rs | 3 ++- rust/src/main.rs | 1 + rust/src/{domain/register => }/retry.rs | 3 ++- 6 files changed, 9 insertions(+), 6 deletions(-) rename rust/src/{domain/register => }/retry.rs (97%) diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs index c5be1da3..4a235a15 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -19,8 +19,6 @@ use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; #[allow(clippy::print_stderr)] pub(crate) mod bridge; #[allow(clippy::print_stderr)] -pub(crate) mod retry; -#[allow(clippy::print_stderr)] pub(crate) mod steps; #[allow(clippy::print_stderr)] pub(crate) mod wizard; diff --git a/rust/src/domain/register/steps/discovery.rs b/rust/src/domain/register/steps/discovery.rs index cfc71390..02ae85db 100644 --- a/rust/src/domain/register/steps/discovery.rs +++ b/rust/src/domain/register/steps/discovery.rs @@ -9,7 +9,8 @@ use crate::domain::common::{ api_error, make_client_with_cred, term_for_period, validate_domain_name, }; -use super::super::retry::with_retry; +use crate::retry::with_retry; + use super::super::wizard::{StepContext, StepResult, WizardState}; /// Maximum suggestions to show when a domain is taken. diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs index 6e58c5d1..4ee4a175 100644 --- a/rust/src/domain/register/steps/execute.rs +++ b/rust/src/domain/register/steps/execute.rs @@ -12,7 +12,8 @@ use crate::domain::common::{ }; use crate::quote_cache; -use super::super::retry::with_retry; +use crate::retry::with_retry; + use super::super::wizard::{StepContext, StepResult, WizardState}; pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs index f7d2a9ca..3dba611e 100644 --- a/rust/src/domain/register/steps/review.rs +++ b/rust/src/domain/register/steps/review.rs @@ -15,7 +15,8 @@ use crate::domain::common::{ use crate::environments; use crate::quote_cache; -use super::super::retry::with_retry; +use crate::retry::with_retry; + use super::super::wizard::{ContactsChoice, StepContext, StepResult, WizardState}; pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { diff --git a/rust/src/main.rs b/rust/src/main.rs index 5db67866..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; diff --git a/rust/src/domain/register/retry.rs b/rust/src/retry.rs similarity index 97% rename from rust/src/domain/register/retry.rs rename to rust/src/retry.rs index 19fc65d2..ebe7a501 100644 --- a/rust/src/domain/register/retry.rs +++ b/rust/src/retry.rs @@ -1,4 +1,4 @@ -//! Retry helper for transient network errors during wizard API calls. +//! Retry helper for transient network errors during API calls. use std::future::Future; use std::time::Duration; @@ -8,6 +8,7 @@ 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, From 1b44de8ad3d3cfaf387da272c57b82d702235a1d Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 25 Aug 2026 14:53:33 -0400 Subject: [PATCH 05/24] fix lint --- rust/src/retry.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rust/src/retry.rs b/rust/src/retry.rs index ebe7a501..5681c288 100644 --- a/rust/src/retry.rs +++ b/rust/src/retry.rs @@ -61,15 +61,15 @@ mod tests { #[tokio::test] async fn succeeds_on_first_attempt() { - let result: std::result::Result<&str, String> = + let result: Result<&str, String> = with_retry("test", 3, || async { Ok("ok") }).await; - assert_eq!(result.unwrap(), "ok"); + assert_eq!(result.expect("should succeed"), "ok"); } #[tokio::test] async fn retries_on_transient_error() { let attempts = AtomicU32::new(0); - let result: std::result::Result<&str, String> = with_retry("test", 3, || { + let result: Result<&str, String> = with_retry("test", 3, || { let n = attempts.fetch_add(1, Ordering::SeqCst); async move { if n < 2 { @@ -80,14 +80,14 @@ mod tests { } }) .await; - assert_eq!(result.unwrap(), "recovered"); + 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: std::result::Result<&str, String> = with_retry("test", 3, || { + let result: Result<&str, String> = with_retry("test", 3, || { attempts.fetch_add(1, Ordering::SeqCst); async { Err("404 not found".to_owned()) } }) @@ -99,7 +99,7 @@ mod tests { #[tokio::test] async fn exhausts_retries() { let attempts = AtomicU32::new(0); - let result: std::result::Result<&str, String> = with_retry("test", 3, || { + let result: Result<&str, String> = with_retry("test", 3, || { attempts.fetch_add(1, Ordering::SeqCst); async { Err("503 service unavailable".to_owned()) } }) From 1f151aeea248f53026e4ab96e8623ec66df00273 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 26 Aug 2026 08:38:30 -0400 Subject: [PATCH 06/24] Fix prompt formatting issues --- rust/Cargo.lock | 4 --- rust/Cargo.toml | 3 ++ rust/src/domain/register/bridge.rs | 40 +++++++++++---------- rust/src/domain/register/mod.rs | 11 ++++-- rust/src/domain/register/steps/contacts.rs | 6 ++-- rust/src/domain/register/steps/discovery.rs | 2 +- rust/src/domain/register/steps/options.rs | 2 +- rust/src/domain/register/wizard.rs | 13 +++---- 8 files changed, 45 insertions(+), 36 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a58c3f6f..8110ce3d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -545,8 +545,6 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abdd934fca13a77d706d45bae8289d7d35f90ff077c99dd7404bda279a3bd3af" dependencies = [ "async-trait", "base64", @@ -579,8 +577,6 @@ dependencies = [ [[package]] name = "cli-engine-macros" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2e642cc4e1aa5a6ba1ad3f52de13bf1895ee7807384a272e1bb2dcee77e6b8" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b2415ad2..e2fca289 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,6 +15,9 @@ path = "src/main.rs" [build-dependencies] chrono = { version = "0.4", default-features = false, features = ["clock"] } +[patch.crates-io] +cli-engine = { path = "../../cli-engine/cli-engine" } + [dependencies] async-trait = "0.1" bytes = "1" diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs index 4a10f263..d7b02c1f 100644 --- a/rust/src/domain/register/bridge.rs +++ b/rust/src/domain/register/bridge.rs @@ -33,8 +33,7 @@ pub(crate) async fn offer_registration_from_available( state.price = price; state.currency = currency; - let result = super::launch_wizard(ctx, state, 1).await?; - Ok(Some(result)) + super::launch_wizard(ctx, state, 1).await } /// After `domain suggest` displays results, offer to pick one and register. @@ -47,27 +46,33 @@ pub(crate) async fn offer_registration_from_suggest( return Ok(None); } - let proceed = Confirm::new() - .with_prompt("Would you like to register one of these domains?") - .default(false) - .interact() - .unwrap_or(false); - - if !proceed { - 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()); let selection = dialoguer::Select::new() - .with_prompt("Select a domain") + .with_prompt("Would you like to register one of these domains? Select one to proceed") .items(&items) - .default(0) + .default(items.len() - 1) .interact() .unwrap_or(items.len() - 1); - let domain = if selection == items.len() - 1 { + // 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()) @@ -76,11 +81,9 @@ pub(crate) async fn offer_registration_from_suggest( let mut state = WizardState::new().with_domain(domain.clone()); if domain.is_some() { state.available = true; - let result = super::launch_wizard(ctx, state, 1).await?; - Ok(Some(result)) + super::launch_wizard(ctx, state, 1).await } else { - let result = super::launch_wizard(ctx, state, 0).await?; - Ok(Some(result)) + super::launch_wizard(ctx, state, 0).await } } @@ -117,6 +120,5 @@ pub(crate) async fn offer_registration_from_quote( state.currency = currency; // Start at step 3 (Review & Confirm) since quote is already done. - let result = super::launch_wizard(ctx, state, 3).await?; - Ok(Some(result)) + super::launch_wizard(ctx, state, 3).await } diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs index 4a235a15..6159d330 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -128,6 +128,9 @@ async fn run_interactive( 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) } @@ -181,11 +184,12 @@ async fn run_non_interactive( /// 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.). +/// Returns `None` if the user cancelled the wizard (no error, no output needed). pub(crate) async fn launch_wizard( ctx: &cli_engine::CommandContext, state: WizardState, start_at: usize, -) -> Result { +) -> Result> { let cred = ctx.credential().await?; let env = ctx.middleware.env.clone(); let debug = !ctx.middleware.debug.is_empty(); @@ -197,7 +201,10 @@ pub(crate) async fn launch_wizard( }; let final_state = wizard::run_wizard(state, step_ctx, start_at).await?; - build_result(&final_state) + if final_state.cancelled { + return Ok(None); + } + build_result(&final_state).map(Some) } fn build_result(state: &WizardState) -> Result { diff --git a/rust/src/domain/register/steps/contacts.rs b/rust/src/domain/register/steps/contacts.rs index 5d6d3cf9..02b988b9 100644 --- a/rust/src/domain/register/steps/contacts.rs +++ b/rust/src/domain/register/steps/contacts.rs @@ -171,7 +171,7 @@ fn collect_contacts_interactively() -> Result { fn prompt_required(label: &str) -> Result { let value: String = Input::new() - .with_prompt(format!(" {label}")) + .with_prompt(format!(" Enter {label}")) .interact_text() .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; let trimmed = value.trim().to_owned(); @@ -183,7 +183,7 @@ fn prompt_required(label: &str) -> Result { fn prompt_optional(label: &str) -> Result> { let value: String = Input::new() - .with_prompt(format!(" {label}")) + .with_prompt(format!(" Enter {label} (optional)")) .allow_empty(true) .interact_text() .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; @@ -201,7 +201,7 @@ fn prompt_validated( ) -> Result { loop { let value: String = Input::new() - .with_prompt(format!(" {label}")) + .with_prompt(format!(" Enter {label}")) .interact_text() .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; let trimmed = value.trim().to_owned(); diff --git a/rust/src/domain/register/steps/discovery.rs b/rust/src/domain/register/steps/discovery.rs index 02ae85db..3957623d 100644 --- a/rust/src/domain/register/steps/discovery.rs +++ b/rust/src/domain/register/steps/discovery.rs @@ -85,7 +85,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result { let input: String = Input::new() - .with_prompt("Domain name to register") + .with_prompt("Enter domain name to register") .validate_with(|input: &String| -> std::result::Result<(), String> { validate_domain_name(input) .map(|_| ()) diff --git a/rust/src/domain/register/steps/options.rs b/rust/src/domain/register/steps/options.rs index 33a908dd..3e114f9a 100644 --- a/rust/src/domain/register/steps/options.rs +++ b/rust/src/domain/register/steps/options.rs @@ -91,7 +91,7 @@ fn prompt_nameservers() -> Result> { eprintln!(" Enter nameservers (empty line to finish, min 2):"); loop { let ns: String = Input::new() - .with_prompt(format!(" NS {}", nameservers.len() + 1)) + .with_prompt(format!(" Enter NS {}", nameservers.len() + 1)) .allow_empty(true) .interact_text() .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs index f9c381d2..11f19933 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -50,6 +50,9 @@ pub(crate) struct WizardState { // 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, } /// How contacts are supplied for the registration. @@ -158,9 +161,8 @@ pub(crate) async fn run_wizard( "\n {} Interrupted. No charges were made.", style("✗").red().bold() ); - return Err(cli_engine::CliCoreError::message( - "domain registration interrupted by user", - )); + state.cancelled = true; + return Ok(state); } Err(e) => return Err(e), }; @@ -180,9 +182,8 @@ pub(crate) async fn run_wizard( "\n {} Wizard cancelled. No charges were made.", style("✗").red().bold() ); - return Err(cli_engine::CliCoreError::message( - "domain registration cancelled by user", - )); + state.cancelled = true; + return Ok(state); } } } From 67ff1d9204511c98e732ca4e1e2b9784b0f00377 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 26 Aug 2026 08:44:44 -0400 Subject: [PATCH 07/24] Fix cargo file pointing to local cli-engine --- rust/Cargo.lock | 7 ++++++- rust/Cargo.toml | 3 --- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8110ce3d..78f6ff3f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -544,7 +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 = "45f7570f3516078ba03dba1e310bf092161bac656bd9bd3bfd6fb0d7b419dfdf" dependencies = [ "async-trait", "base64", @@ -564,6 +566,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "strsim", "termimad", "thiserror", "tokio", @@ -577,6 +580,8 @@ dependencies = [ [[package]] name = "cli-engine-macros" version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2e642cc4e1aa5a6ba1ad3f52de13bf1895ee7807384a272e1bb2dcee77e6b8" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e2fca289..b2415ad2 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,9 +15,6 @@ path = "src/main.rs" [build-dependencies] chrono = { version = "0.4", default-features = false, features = ["clock"] } -[patch.crates-io] -cli-engine = { path = "../../cli-engine/cli-engine" } - [dependencies] async-trait = "0.1" bytes = "1" From eff9c66f49551f7bf2bb2af5e2b28be580d1a4f0 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 26 Aug 2026 08:48:10 -0400 Subject: [PATCH 08/24] fix the formatting issue --- rust/src/domain/register/bridge.rs | 4 +--- rust/src/retry.rs | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs index d7b02c1f..1ca4472e 100644 --- a/rust/src/domain/register/bridge.rs +++ b/rust/src/domain/register/bridge.rs @@ -47,9 +47,7 @@ pub(crate) async fn offer_registration_from_suggest( } // Show suggestions inline so the user sees what's available before choosing. - eprintln!( - "\n Here are some available domains based on your input:\n" - ); + eprintln!("\n Here are some available domains based on your input:\n"); for (i, name) in suggestions.iter().enumerate() { eprintln!(" {}. {}", i + 1, name); } diff --git a/rust/src/retry.rs b/rust/src/retry.rs index 5681c288..2d087de8 100644 --- a/rust/src/retry.rs +++ b/rust/src/retry.rs @@ -61,8 +61,7 @@ mod tests { #[tokio::test] async fn succeeds_on_first_attempt() { - let result: Result<&str, String> = - with_retry("test", 3, || async { Ok("ok") }).await; + let result: Result<&str, String> = with_retry("test", 3, || async { Ok("ok") }).await; assert_eq!(result.expect("should succeed"), "ok"); } From ac25fdc63d6da98691b1ad3a00f00c823c8cd357 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 26 Aug 2026 10:14:13 -0400 Subject: [PATCH 09/24] Fix the lint errors --- rust/src/domain/register/steps/execute.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs index 4ee4a175..c3b939ec 100644 --- a/rust/src/domain/register/steps/execute.rs +++ b/rust/src/domain/register/steps/execute.rs @@ -120,7 +120,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result = None; if let Some(op_id) = operation_id.as_ref() { - spinner.set_message(format!("Waiting for registry ({})", &domain)); + spinner.set_message(format!("Waiting for registry ({})", domain)); let mut timed_out = false; for _ in 0..20 { if is_terminal_status(&status) { From 519bcb5e8ba6c089ed0f07a275fbecf5f18a906a Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 26 Aug 2026 11:43:22 -0400 Subject: [PATCH 10/24] Address copilot comments; skip prompts for agree and confirm in non-interactive mode, using a cached quote, using saturated_sub --- rust/src/domain/register/mod.rs | 13 ++- rust/src/domain/register/steps/execute.rs | 9 +- rust/src/domain/register/steps/review.rs | 132 ++++++++++++++++++++++ rust/src/domain/register/wizard.rs | 6 +- 4 files changed, 148 insertions(+), 12 deletions(-) diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs index 6159d330..f752950e 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -161,12 +161,13 @@ async fn run_non_interactive( let env = ctx.middleware.env.clone(); let debug = !ctx.middleware.debug.is_empty(); - let state = WizardState::new() + 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, @@ -174,12 +175,12 @@ async fn run_non_interactive( debug, }; - // In non-interactive mode, we skip the wizard UI and execute the steps - // directly (availability check → quote → register), relying on flags for - // all configuration. - let final_state = wizard::run_wizard(state, step_ctx, 0).await?; + // 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(&final_state) + build_result(&state) } /// Launch the wizard from an external command (e.g. `domain available --interactive`). diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs index c3b939ec..b551cb8c 100644 --- a/rust/src/domain/register/steps/execute.rs +++ b/rust/src/domain/register/steps/execute.rs @@ -1,4 +1,4 @@ -//! Step 4: Execute — submit the registration using the cached quote, poll the +//! Step 5: Execute — submit the registration using the cached quote, poll the //! async operation, and display the result. use cli_engine::{CliCoreError, Result}; @@ -123,7 +123,12 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result 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; diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs index 3dba611e..2cebf6f4 100644 --- a/rust/src/domain/register/steps/review.rs +++ b/rust/src/domain/register/steps/review.rs @@ -239,6 +239,138 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result 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 { diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs index 11f19933..da5fd608 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -172,10 +172,8 @@ pub(crate) async fn run_wizard( current += 1; } StepResult::Back => { - if current > start_at { - current -= 1; - } - // If already at start, the step will re-run (loop continues). + current = current.saturating_sub(1); + // If already at step 0, the step will re-run (loop continues). } StepResult::Cancel => { eprintln!( From cbf0cc0d0f36dfc63ad4014f99699056e6a46e31 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 26 Aug 2026 15:03:29 -0400 Subject: [PATCH 11/24] Fixed Go Back opions\n fixed ordering of wizards --- rust/src/domain/register/bridge.rs | 148 +++++++++++++++++------------ rust/src/domain/register/mod.rs | 23 ++++- rust/src/domain/register/wizard.rs | 18 +++- 3 files changed, 122 insertions(+), 67 deletions(-) diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs index 1ca4472e..a65b0aa6 100644 --- a/rust/src/domain/register/bridge.rs +++ b/rust/src/domain/register/bridge.rs @@ -1,13 +1,15 @@ //! Bridge functions allowing other domain commands (suggest, available, quote) //! to hand off to the registration wizard when running interactively. -use cli_engine::{CommandResult, Result}; +use cli_engine::{CliCoreError, CommandResult, Result}; use dialoguer::Confirm; use super::wizard::WizardState; +use super::WizardExit; /// After `domain available` finds a domain is available, offer to continue -/// with registration. Returns `None` if the user declines. +/// 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, @@ -18,26 +20,33 @@ pub(crate) async fn offer_registration_from_available( return Ok(None); } - let proceed = Confirm::new() - .with_prompt(format!("Would you like to register {domain}?")) - .default(false) - .interact() - .unwrap_or(false); + 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); - } + if !proceed { + return Ok(None); + } - let mut state = WizardState::new().with_domain(Some(domain.to_owned())); - state.available = true; - state.price = price; - state.currency = currency; - - super::launch_wizard(ctx, state, 1).await + 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(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. +/// 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], @@ -57,36 +66,47 @@ pub(crate) async fn offer_registration_from_suggest( items.push("(enter a different domain)".to_owned()); items.push("(skip — just show results)".to_owned()); - 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() - .unwrap_or(items.len() - 1); - - // 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()); - if domain.is_some() { - state.available = true; - super::launch_wizard(ctx, state, 1).await - } else { - super::launch_wizard(ctx, state, 0).await + 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(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. +/// 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, @@ -99,24 +119,30 @@ pub(crate) async fn offer_registration_from_quote( return Ok(None); } - let proceed = Confirm::new() - .with_prompt(format!("Would you like to purchase {domain} now?")) - .default(false) - .interact() - .unwrap_or(false); - - if !proceed { - 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}")))?; - 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; - state.currency = currency; + if !proceed { + return Ok(None); + } - // Start at step 3 (Review & Confirm) since quote is already done. - super::launch_wizard(ctx, state, 3).await + 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(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 index f752950e..dfb5a342 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -183,14 +183,26 @@ async fn run_non_interactive( 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.). -/// Returns `None` if the user cancelled the wizard (no error, no output needed). pub(crate) async fn launch_wizard( ctx: &cli_engine::CommandContext, state: WizardState, start_at: usize, -) -> Result> { +) -> Result { let cred = ctx.credential().await?; let env = ctx.middleware.env.clone(); let debug = !ctx.middleware.debug.is_empty(); @@ -202,10 +214,13 @@ pub(crate) async fn launch_wizard( }; 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(None); + return Ok(WizardExit::Cancelled); } - build_result(&final_state).map(Some) + build_result(&final_state).map(WizardExit::Completed) } fn build_result(state: &WizardState) -> Result { diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs index da5fd608..7e0a1682 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -53,6 +53,8 @@ pub(crate) struct WizardState { // 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. @@ -172,8 +174,20 @@ pub(crate) async fn run_wizard( current += 1; } StepResult::Back => { - current = current.saturating_sub(1); - // If already at step 0, the step will re-run (loop continues). + 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!( From d01ee6beac50292197587ba675d9e60157df1e5c Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Thu, 27 Aug 2026 13:21:28 -0400 Subject: [PATCH 12/24] lint fix --- rust/src/domain/register/bridge.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs index a65b0aa6..aec9c3a4 100644 --- a/rust/src/domain/register/bridge.rs +++ b/rust/src/domain/register/bridge.rs @@ -4,8 +4,8 @@ use cli_engine::{CliCoreError, CommandResult, Result}; use dialoguer::Confirm; -use super::wizard::WizardState; 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 @@ -68,9 +68,7 @@ pub(crate) async fn offer_registration_from_suggest( loop { let selection = dialoguer::Select::new() - .with_prompt( - "Would you like to register one of these domains? Select one to proceed", - ) + .with_prompt("Would you like to register one of these domains? Select one to proceed") .items(&items) .default(items.len() - 1) .interact() From 2fe2db4b48c820bac0c6271766e4615dd53ed580 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Thu, 27 Aug 2026 16:10:25 -0400 Subject: [PATCH 13/24] fix(domain): address wizard UX review on suggest bridge and review UI - Number mid-flow steps relative to the entry point (1/4 from suggest, not 2/5) - Pad the order summary with visible width so ANSI styles keep the box aligned - Drop duplicate stderr next-steps; envelope owns a single footer with domain filled - Render register-shaped human output when the wizard returns via suggest/available/quote --- rust/src/domain/register/bridge.rs | 12 +- rust/src/domain/register/mod.rs | 161 ++++++++++++++++++++-- rust/src/domain/register/steps/execute.rs | 7 +- rust/src/domain/register/steps/review.rs | 104 ++++++++++---- rust/src/domain/register/wizard.rs | 30 +++- 5 files changed, 263 insertions(+), 51 deletions(-) diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs index aec9c3a4..07310fe6 100644 --- a/rust/src/domain/register/bridge.rs +++ b/rust/src/domain/register/bridge.rs @@ -37,7 +37,9 @@ pub(crate) async fn offer_registration_from_available( state.currency = currency.clone(); match super::launch_wizard(ctx, state, 1).await? { - WizardExit::Completed(result) => return Ok(Some(result)), + WizardExit::Completed(result) => { + return Ok(Some(super::present_for_host_command(ctx, result))); + } WizardExit::BackedOut => continue, WizardExit::Cancelled => return Ok(None), } @@ -95,7 +97,9 @@ pub(crate) async fn offer_registration_from_suggest( }; match exit { - WizardExit::Completed(result) => return Ok(Some(result)), + WizardExit::Completed(result) => { + return Ok(Some(super::present_for_host_command(ctx, result))); + } WizardExit::BackedOut => continue, WizardExit::Cancelled => return Ok(None), } @@ -138,7 +142,9 @@ pub(crate) async fn offer_registration_from_quote( // 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(result)), + 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 index dfb5a342..534fbd75 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -5,12 +5,13 @@ //! the command validates them and executes directly without prompts. use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, NextActionParam, Result, RuntimeCommandSpec, Tier, + CliCoreError, CommandContext, CommandResult, CommandSpec, Envelope, NextActionParam, Result, + RuntimeCommandSpec, TableColumn, Tier, render_human_with_view, }; use serde_json::json; -use crate::domain::common::validate_domain_name; -use crate::next_action::next_action; +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}; @@ -33,6 +34,16 @@ output_schema!(DomainRegisterResult { "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). @@ -89,6 +100,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .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(); @@ -102,10 +114,7 @@ pub(super) fn command() -> RuntimeCommandSpec { ) } -async fn run_interactive( - ctx: cli_engine::CommandContext, - args: RegisterArgs, -) -> Result { +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(); @@ -134,10 +143,7 @@ async fn run_interactive( build_result(&final_state) } -async fn run_non_interactive( - ctx: cli_engine::CommandContext, - args: RegisterArgs, -) -> Result { +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\ @@ -199,7 +205,7 @@ pub(crate) enum WizardExit { /// 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: &cli_engine::CommandContext, + ctx: &CommandContext, state: WizardState, start_at: usize, ) -> Result { @@ -244,14 +250,69 @@ fn build_result(state: &WizardState) -> Result { result["currency"] = json!(c); } - let actions = vec![ - next_action("domain get ", "See the registered domain's details") - .with_param("domain", NextActionParam::required()), + 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::*; @@ -294,6 +355,76 @@ mod tests { 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. diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs index b551cb8c..9f792b1c 100644 --- a/rust/src/domain/register/steps/execute.rs +++ b/rust/src/domain/register/steps/execute.rs @@ -243,9 +243,10 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result"); + // 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()); diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs index 2cebf6f4..7597a290 100644 --- a/rust/src/domain/register/steps/review.rs +++ b/rust/src/domain/register/steps/review.rs @@ -3,7 +3,7 @@ //! by offering to open the browser for payment method setup. use cli_engine::{CliCoreError, Result}; -use console::style; +use console::{Alignment, pad_str, style}; use dialoguer::{Confirm, Select}; use domains_client::types; @@ -19,6 +19,18 @@ 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 @@ -113,57 +125,57 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result { _ => 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 index 7e0a1682..ec81e487 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -125,6 +125,10 @@ const STEPS: &[StepInfo] = &[ /// /// 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, @@ -132,6 +136,8 @@ pub(crate) async fn run_wizard( ) -> 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 { @@ -139,11 +145,12 @@ pub(crate) async fn run_wizard( } let step = &STEPS[current]; + let display_num = current.saturating_sub(start_at) + 1; eprintln!( "\n {} Step {}/{}: {}", style("─").dim(), - current + 1, - total_steps, + display_num, + display_total, style(step.name).bold() ); @@ -256,6 +263,25 @@ mod tests { 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() From f9f529deea8ddc1bc027c92dcfa707f2fe4697a4 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Fri, 28 Aug 2026 08:28:35 -0400 Subject: [PATCH 14/24] fix(domain): mask contact email preview and improve wizard back navigation --- rust/src/domain/register/mod.rs | 3 + rust/src/domain/register/steps/contacts.rs | 19 ++++- rust/src/domain/register/steps/options.rs | 9 ++- rust/src/domain/register/wizard.rs | 90 ++++++++++++++++++---- 4 files changed, 105 insertions(+), 16 deletions(-) diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs index 534fbd75..12467476 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -133,6 +133,7 @@ async fn run_interactive(ctx: CommandContext, args: RegisterArgs) -> Result Result< credential: cred, env, debug, + wizard_start_at: 0, }; // Non-interactive: skip all interactive prompts. --agree and --confirm @@ -217,6 +219,7 @@ pub(crate) async fn launch_wizard( credential: cred, env, debug, + wizard_start_at: start_at, }; let final_state = wizard::run_wizard(state, step_ctx, start_at).await?; diff --git a/rust/src/domain/register/steps/contacts.rs b/rust/src/domain/register/steps/contacts.rs index 02b988b9..84b8acc7 100644 --- a/rust/src/domain/register/steps/contacts.rs +++ b/rust/src/domain/register/steps/contacts.rs @@ -93,6 +93,16 @@ pub(crate) async fn run(state: &mut WizardState, _ctx: &StepContext) -> Result String { + let (local, domain) = email.split_once('@').unwrap_or((email, "")); + if local.is_empty() || domain.is_empty() { + return "***".to_string(); + } + let visible = local.chars().next().map_or('*', |c| c); + format!("{visible}***@{domain}") +} + fn display_saved_contacts(file: &ContactsFile) { for role in [Role::Registrant, Role::Admin, Role::Billing, Role::Tech] { if let Some(c) = file.get(role) { @@ -102,7 +112,7 @@ fn display_saved_contacts(file: &ContactsFile) { style(role.label()).bold(), c.name_first, c.name_last, - c.email + mask_email(&c.email) ); } } @@ -319,6 +329,13 @@ mod tests { use super::*; use tempfile::NamedTempFile; + #[test] + fn mask_email_hides_local_part() { + assert_eq!(mask_email("jane@example.com"), "j***@example.com"); + assert_eq!(mask_email("a@b.c"), "a***@b.c"); + assert_eq!(mask_email("invalid"), "***"); + } + #[test] fn validate_email_accepts_valid() { assert!(validate_email("user@example.com").is_ok()); diff --git a/rust/src/domain/register/steps/options.rs b/rust/src/domain/register/steps/options.rs index 3e114f9a..ddfd42f4 100644 --- a/rust/src/domain/register/steps/options.rs +++ b/rust/src/domain/register/steps/options.rs @@ -10,7 +10,7 @@ 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 { +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { eprintln!( "\n {} Configuring registration for {}", style("⚙").bold(), @@ -72,7 +72,12 @@ pub(crate) async fn run(state: &mut WizardState, _ctx: &StepContext) -> Result 0 { + "↩ Go back to change domain" + } else { + "↩ Go back to discovery" + }; + let choices = vec!["Continue", back_label]; let selection = Select::new() .items(&choices) .default(0) diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs index ec81e487..81016684 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -22,6 +22,38 @@ pub(crate) struct StepContext { pub credential: Credential, pub env: String, pub debug: bool, + /// Wizard entry step (0 = full flow from `domain register`). + pub wizard_start_at: usize, +} + +/// Result of handling a Back action in the wizard loop. +enum BackTransition { + GoTo { step: usize, clear_domain: bool }, + ExitToBridge, +} + +/// Compute the next step (or bridge exit) when the user chooses Back. +fn back_transition(current: usize, start_at: usize) -> BackTransition { + if current == 0 && start_at > 0 { + return BackTransition::ExitToBridge; + } + if current == start_at { + if start_at > 0 { + let step = start_at - 1; + BackTransition::GoTo { + step, + clear_domain: step == 0, + } + } else { + BackTransition::ExitToBridge + } + } else { + let step = current.saturating_sub(1); + BackTransition::GoTo { + step, + clear_domain: step == 0, + } + } } /// Accumulated state across all wizard steps. @@ -131,9 +163,10 @@ const STEPS: &[StepInfo] = &[ /// so the counter matches the work the user still has to do. pub(crate) async fn run_wizard( mut state: WizardState, - ctx: StepContext, + mut ctx: StepContext, start_at: usize, ) -> Result { + ctx.wizard_start_at = start_at; let total_steps = STEPS.len(); let mut current = start_at; // Relative denominator: how many steps this entry point will show. @@ -180,22 +213,19 @@ pub(crate) async fn run_wizard( 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. + StepResult::Back => match back_transition(current, start_at) { + BackTransition::ExitToBridge => { 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; + BackTransition::GoTo { step, clear_domain } => { + current = step; + if clear_domain { + state.domain = None; + state.available = false; + } } - } + }, StepResult::Cancel => { eprintln!( "\n {} Wizard cancelled. No charges were made.", @@ -263,6 +293,40 @@ mod tests { assert_eq!(STEPS[4].name, "Register"); } + #[test] + fn back_transition_from_bridge_entry_steps() { + // suggest/available enter at Options — back goes to Discovery, not bridge. + assert!(matches!( + back_transition(1, 1), + BackTransition::GoTo { + step: 0, + clear_domain: true + } + )); + // Discovery after bridge entry — return to bridge UI. + assert!(matches!( + back_transition(0, 1), + BackTransition::ExitToBridge + )); + + // quote enters at Review — back goes to Contacts, not bridge. + assert!(matches!( + back_transition(3, 3), + BackTransition::GoTo { + step: 2, + clear_domain: false + } + )); + // Mid-flow back within full wizard. + assert!(matches!( + back_transition(2, 0), + BackTransition::GoTo { + step: 1, + clear_domain: false + } + )); + } + #[test] fn mid_flow_step_counter_is_relative_to_entry_point() { // From suggest/available the wizard starts at Options (index 1): From 3d094cf820c4b9051944c86d3a7691f896664f9d Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Fri, 28 Aug 2026 09:12:48 -0400 Subject: [PATCH 15/24] fix(domain): labesl in the order summary --- rust/src/domain/register/steps/review.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs index 7597a290..fd1f1c41 100644 --- a/rust/src/domain/register/steps/review.rs +++ b/rust/src/domain/register/steps/review.rs @@ -146,7 +146,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result Date: Fri, 28 Aug 2026 09:42:35 -0400 Subject: [PATCH 16/24] fix(domain):update cli-engine, lint error, pii warning --- rust/Cargo.lock | 4 +-- rust/Cargo.toml | 2 +- rust/src/domain/register/steps/contacts.rs | 29 +++------------------- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 78f6ff3f..603289c3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -544,9 +544,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45f7570f3516078ba03dba1e310bf092161bac656bd9bd3bfd6fb0d7b419dfdf" +checksum = "53fa000864b13d637997df7f5b03045b6b0a78f875d10dc583a5ecc6ec4868e5" dependencies = [ "async-trait", "base64", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b2415ad2..011658a7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -20,7 +20,7 @@ async-trait = "0.1" bytes = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4.5", features = ["std", "string"] } -cli-engine = { features = ["pkce-auth"], version = "0.9.0" } +cli-engine = { features = ["pkce-auth"], version = "0.9.2" } dirs = "6" domains-client = { path = "domains-client" } fancy-regex = "0.14" diff --git a/rust/src/domain/register/steps/contacts.rs b/rust/src/domain/register/steps/contacts.rs index 84b8acc7..00ed7cc9 100644 --- a/rust/src/domain/register/steps/contacts.rs +++ b/rust/src/domain/register/steps/contacts.rs @@ -93,27 +93,11 @@ pub(crate) async fn run(state: &mut WizardState, _ctx: &StepContext) -> Result String { - let (local, domain) = email.split_once('@').unwrap_or((email, "")); - if local.is_empty() || domain.is_empty() { - return "***".to_string(); - } - let visible = local.chars().next().map_or('*', |c| c); - format!("{visible}***@{domain}") -} - fn display_saved_contacts(file: &ContactsFile) { + eprintln!(" Saved contacts from contacts.toml:"); 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, - mask_email(&c.email) - ); + if file.get(role).is_some() { + eprintln!(" {} {}", style("•").dim(), style(role.label()).bold(),); } } } @@ -329,13 +313,6 @@ mod tests { use super::*; use tempfile::NamedTempFile; - #[test] - fn mask_email_hides_local_part() { - assert_eq!(mask_email("jane@example.com"), "j***@example.com"); - assert_eq!(mask_email("a@b.c"), "a***@b.c"); - assert_eq!(mask_email("invalid"), "***"); - } - #[test] fn validate_email_accepts_valid() { assert!(validate_email("user@example.com").is_ok()); From f1561e753a7fec03ae215c80791c7936aabdd2c7 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Fri, 28 Aug 2026 10:48:18 -0400 Subject: [PATCH 17/24] fix(domain): Misc issues: * Dynamic list of years based on the quote api. * Doesn't show the pricing table if purchase is cancelled or interrupted --- rust/src/domain/available.rs | 24 +-- rust/src/domain/common.rs | 82 ++++++++++ rust/src/domain/quote.rs | 24 +-- rust/src/domain/register/bridge.rs | 58 +++++--- rust/src/domain/register/mod.rs | 18 +++ rust/src/domain/register/steps/discovery.rs | 8 +- rust/src/domain/register/steps/options.rs | 157 ++++++++++++++++++-- rust/src/domain/register/steps/review.rs | 56 ++++++- rust/src/domain/register/wizard.rs | 3 + rust/src/domain/suggest.rs | 8 +- 10 files changed, 367 insertions(+), 71 deletions(-) diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index 7c512637..e1a35adc 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -8,7 +8,8 @@ use serde_json::json; use domains_client::types; use super::common::{ - api_error, format_money, make_client, period_label, term_for_period, validate_domain_name, + api_error, format_money, make_client, period_label, periods_from_prices, term_for_period, + validate_domain_name, }; use crate::next_action::next_action; use crate::output_schema::output_schema; @@ -166,16 +167,19 @@ pub(super) fn command() -> RuntimeCommandSpec { .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? + match super::register::bridge::offer_registration_from_available( + &ctx, + &resolved_domain, + price_1yr, + currency_str, + periods_from_prices(&prices), + ) + .await? { - return Ok(wizard_result); + super::register::BridgeHandoff::Replace(wizard_result) => { + return Ok(wizard_result); + } + super::register::BridgeHandoff::ShowHostOutput => {} } Ok(cmd.with_next_actions(vec![ diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index 6b2db862..cf953bb0 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -91,6 +91,47 @@ pub(super) fn term_for_period( prices.iter().find(|p| p.period == Some(period)) } +/// Sorted, unique registration periods (years) priced in an availability response. +pub(super) fn periods_from_prices(prices: &[types::TermPrice]) -> Vec { + let mut periods: Vec = prices + .iter() + .filter_map(|t| t.period.map(|p| p.get())) + .collect(); + periods.sort_unstable(); + periods.dedup(); + periods +} + +/// Whether an API error body indicates the requested registration period exceeds +/// the TLD limit. Availability pricing is indicative; quote is authoritative. +pub(super) fn is_period_limit_error(body: &str) -> bool { + let lower = body.to_ascii_lowercase(); + lower.contains("not currently supported") || lower.contains("maximum is") +} + +/// Parse the maximum registration period from a period-limit error body. +pub(super) fn parse_max_registration_period(body: &str) -> Option { + let lower = body.to_ascii_lowercase(); + let needle = "maximum is "; + let rest = lower.split(needle).nth(1)?; + rest.split_whitespace().next()?.parse().ok() +} + +/// Drop unsupported periods and clamp the selected period to the TLD maximum. +pub(super) fn clamp_registration_periods( + available_periods: &mut Vec, + selected_period: &mut u64, + max_years: u64, +) { + available_periods.retain(|p| *p <= max_years); + if available_periods.is_empty() { + available_periods.push(1); + } + if *selected_period > max_years { + *selected_period = available_periods.iter().copied().max().unwrap_or(1); + } +} + /// A registration length with its unit spelled out ("1 year", "2 years") — a /// bare number reads ambiguously in a table, so `quote`/`available` show this /// alongside the numeric `period` field (which stays a plain number for @@ -492,6 +533,47 @@ mod tests { assert_eq!(comma_joined(Vec::::new()), Vec::::new()); } + #[test] + fn periods_from_prices_returns_sorted_unique_periods() { + use domains_client::types; + + let prices = vec![ + types::TermPrice { + period: std::num::NonZeroU64::new(3), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(1), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(2), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(2), + ..Default::default() + }, + ]; + assert_eq!(periods_from_prices(&prices), vec![1, 2, 3]); + } + + #[test] + fn parse_max_registration_period_reads_api_error_text() { + let body = r#"{"details":[{"description":"period 5 is not currently supported; maximum is 3 years"}]}"#; + assert!(is_period_limit_error(body)); + assert_eq!(parse_max_registration_period(body), Some(3)); + } + + #[test] + fn clamp_registration_periods_drops_and_clamps_selection() { + let mut periods = vec![1_u64, 2, 3, 5]; + let mut selected = 5_u64; + clamp_registration_periods(&mut periods, &mut selected, 3); + assert_eq!(periods, vec![1, 2, 3]); + assert_eq!(selected, 3); + } + fn money(value: Option, currency: &str) -> types::SimpleMoney { types::SimpleMoney { value, diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 34a6590e..4138a43a 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -406,18 +406,20 @@ pub(super) fn command() -> RuntimeCommandSpec { .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? + match super::register::bridge::offer_registration_from_quote( + &ctx, + &domain, + &token, + quote_price, + quote_currency, + period, + ) + .await? { - return Ok(wizard_result); + super::register::BridgeHandoff::Replace(wizard_result) => { + return Ok(wizard_result); + } + super::register::BridgeHandoff::ShowHostOutput => {} } next_actions.push( diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs index 07310fe6..fb636789 100644 --- a/rust/src/domain/register/bridge.rs +++ b/rust/src/domain/register/bridge.rs @@ -1,23 +1,24 @@ //! 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 cli_engine::{CliCoreError, Result}; use dialoguer::Confirm; -use super::WizardExit; use super::wizard::WizardState; +use super::{BridgeHandoff, WizardExit, cancelled_host_result}; /// 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. +/// with registration. Returns [`BridgeHandoff::ShowHostOutput`] if the user +/// declines; 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> { + available_periods: Vec, +) -> Result { if !ctx.is_interactive() { - return Ok(None); + return Ok(BridgeHandoff::ShowHostOutput); } loop { @@ -28,33 +29,38 @@ pub(crate) async fn offer_registration_from_available( .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; if !proceed { - return Ok(None); + return Ok(BridgeHandoff::ShowHostOutput); } let mut state = WizardState::new().with_domain(Some(domain.to_owned())); state.available = true; state.price = price.clone(); state.currency = currency.clone(); + state.available_periods = available_periods.clone(); match super::launch_wizard(ctx, state, 1).await? { WizardExit::Completed(result) => { - return Ok(Some(super::present_for_host_command(ctx, result))); + return Ok(BridgeHandoff::Replace(super::present_for_host_command( + ctx, result, + ))); } WizardExit::BackedOut => continue, - WizardExit::Cancelled => return Ok(None), + WizardExit::Cancelled => { + return Ok(BridgeHandoff::Replace(cancelled_host_result(ctx))); + } } } } /// 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. +/// Returns [`BridgeHandoff::ShowHostOutput`] if the user chooses to skip. +/// 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> { +) -> Result { if !ctx.is_interactive() || suggestions.is_empty() { - return Ok(None); + return Ok(BridgeHandoff::ShowHostOutput); } // Show suggestions inline so the user sees what's available before choosing. @@ -78,7 +84,7 @@ pub(crate) async fn offer_registration_from_suggest( // Last option = skip, return None to let normal output render. if selection == items.len() - 1 { - return Ok(None); + return Ok(BridgeHandoff::ShowHostOutput); } // Second-to-last = enter a different domain. @@ -98,16 +104,20 @@ pub(crate) async fn offer_registration_from_suggest( match exit { WizardExit::Completed(result) => { - return Ok(Some(super::present_for_host_command(ctx, result))); + return Ok(BridgeHandoff::Replace(super::present_for_host_command( + ctx, result, + ))); } WizardExit::BackedOut => continue, - WizardExit::Cancelled => return Ok(None), + WizardExit::Cancelled => { + return Ok(BridgeHandoff::Replace(cancelled_host_result(ctx))); + } } } } /// 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 +/// Returns [`BridgeHandoff::ShowHostOutput`] if the user declines. Re-asks if /// the user navigates back from the wizard. pub(crate) async fn offer_registration_from_quote( ctx: &cli_engine::CommandContext, @@ -116,9 +126,9 @@ pub(crate) async fn offer_registration_from_quote( price: Option, currency: Option, period: u64, -) -> Result> { +) -> Result { if !ctx.is_interactive() { - return Ok(None); + return Ok(BridgeHandoff::ShowHostOutput); } loop { @@ -129,7 +139,7 @@ pub(crate) async fn offer_registration_from_quote( .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; if !proceed { - return Ok(None); + return Ok(BridgeHandoff::ShowHostOutput); } let mut state = WizardState::new() @@ -143,10 +153,14 @@ pub(crate) async fn offer_registration_from_quote( // 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))); + return Ok(BridgeHandoff::Replace(super::present_for_host_command( + ctx, result, + ))); } WizardExit::BackedOut => continue, - WizardExit::Cancelled => return Ok(None), + WizardExit::Cancelled => { + return Ok(BridgeHandoff::Replace(cancelled_host_result(ctx))); + } } } } diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs index 12467476..0e98f2d3 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -191,6 +191,24 @@ async fn run_non_interactive(ctx: CommandContext, args: RegisterArgs) -> Result< build_result(&state) } +/// Result of an interactive bridge from another domain command into the wizard. +pub(crate) enum BridgeHandoff { + /// Replace the host command's normal stdout output (wizard completed or cancelled). + Replace(CommandResult), + /// Run the host command's normal output (user declined the bridge prompt). + ShowHostOutput, +} + +/// Host-command output after the user cancels the wizard. The wizard already +/// wrote a cancellation message to stderr; stdout should stay quiet in human mode. +pub(crate) fn cancelled_host_result(ctx: &CommandContext) -> CommandResult { + if ctx.middleware.output_format == "human" { + CommandResult::new(json!("")) + } else { + CommandResult::new(json!({"status": "cancelled"})) + } +} + /// Wizard exit disposition, distinguishing user-initiated back-navigation from /// explicit cancellation. pub(crate) enum WizardExit { diff --git a/rust/src/domain/register/steps/discovery.rs b/rust/src/domain/register/steps/discovery.rs index 3957623d..34f3815d 100644 --- a/rust/src/domain/register/steps/discovery.rs +++ b/rust/src/domain/register/steps/discovery.rs @@ -6,7 +6,7 @@ use console::style; use dialoguer::{Input, Select}; use crate::domain::common::{ - api_error, make_client_with_cred, term_for_period, validate_domain_name, + api_error, make_client_with_cred, periods_from_prices, term_for_period, validate_domain_name, }; use crate::retry::with_retry; @@ -48,6 +48,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result { if selection == 0 { state.domain = None; state.available = false; + state.available_periods.clear(); Ok(StepResult::Back) } else { Ok(StepResult::Cancel) @@ -189,6 +193,7 @@ struct SuggestionEntry { domain: String, price: Option, currency: Option, + available_periods: Vec, } /// Extract unique suggestions from raw API items, capped at `max`. @@ -220,6 +225,7 @@ fn collect_suggestions( domain: name.to_owned(), price, currency, + available_periods: periods_from_prices(prices), }); } all diff --git a/rust/src/domain/register/steps/options.rs b/rust/src/domain/register/steps/options.rs index ddfd42f4..f723abd5 100644 --- a/rust/src/domain/register/steps/options.rs +++ b/rust/src/domain/register/steps/options.rs @@ -4,11 +4,15 @@ use cli_engine::{CliCoreError, Result}; use console::style; use dialoguer::{Confirm, Input, Select}; +use domains_client::types; -use super::super::wizard::{StepContext, StepResult, WizardState}; +use crate::domain::common::{ + api_error, clamp_registration_periods, is_period_limit_error, make_client_with_cred, + parse_max_registration_period, period_label, periods_from_prices, validate_domain_name, +}; +use crate::retry::with_retry; -/// Available registration periods (years). -const PERIOD_OPTIONS: &[u64] = &[1, 2, 3, 5, 10]; +use super::super::wizard::{StepContext, StepResult, WizardState}; pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { eprintln!( @@ -17,25 +21,28 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result = PERIOD_OPTIONS + ensure_available_periods(state, ctx).await?; + refine_periods_with_quote_limit(state, ctx).await?; + + let period_labels: Vec = state + .available_periods .iter() - .map(|p| { - if *p == 1 { - "1 year".to_string() - } else { - format!("{p} years") - } - }) + .map(|p| period_label(*p)) .collect(); + let default_idx = state + .available_periods + .iter() + .position(|&p| p == state.period) + .unwrap_or(0); + let period_idx = Select::new() .with_prompt("Registration period") .items(&period_labels) - .default(0) + .default(default_idx) .interact() .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; - state.period = PERIOD_OPTIONS[period_idx]; + state.period = state.available_periods[period_idx]; // Privacy protection. state.privacy = Confirm::new() @@ -91,6 +98,113 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result<()> { + if !state.available_periods.is_empty() { + return Ok(()); + } + + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))?; + let domain = validate_domain_name(domain)?; + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + 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), + }; + + if !availability.available.unwrap_or(false) { + return Err(CliCoreError::message(format!( + "{domain} is no longer available for registration" + ))); + } + + state.available_periods = periods_from_prices(&availability.prices.unwrap_or_default()); + if state.available_periods.is_empty() { + state.available_periods = vec![1]; + } + + Ok(()) +} + +/// Availability pricing is indicative; probe the quote endpoint with the longest +/// offered period to learn the TLD's authoritative maximum. +async fn refine_periods_with_quote_limit(state: &mut WizardState, ctx: &StepContext) -> Result<()> { + let Some(max_offered) = state.available_periods.last().copied() else { + return Ok(()); + }; + if max_offered <= 1 { + return Ok(()); + } + + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + let domain = validate_domain_name(&domain)?; + + let period_nz = std::num::NonZeroU64::new(max_offered) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let profile = types::InlineRegistrationProfile { + auto_renew: Some(state.auto_renew), + contacts: None, + name_servers: None, + privacy: Some(state.privacy), + }; + + match client + .quote_domain_registration() + .body(types::QuoteDomainRegistrationBody { + domain, + period: period_nz, + profile: Some(profile), + profile_id: None, + }) + .send() + .await + { + Ok(_) => Ok(()), + Err(domains_client::Error::UnexpectedResponse(resp)) => { + let body = resp.text().await.unwrap_or_default(); + if is_period_limit_error(&body) + && let Some(max_years) = parse_max_registration_period(&body) + { + let before = state.available_periods.len(); + clamp_registration_periods( + &mut state.available_periods, + &mut state.period, + max_years, + ); + if state.available_periods.len() < before { + eprintln!( + " {} This TLD supports up to {} years; longer indicative \ + prices from availability were removed.", + style("ℹ").cyan().bold(), + max_years + ); + } + } + Ok(()) + } + Err(e) => Err(api_error("domain quote", ctx.debug, e).await), + } +} + fn prompt_nameservers() -> Result> { let mut nameservers = Vec::new(); eprintln!(" Enter nameservers (empty line to finish, min 2):"); @@ -109,8 +223,7 @@ fn prompt_nameservers() -> Result> { break; } - // Validate nameserver format. - match crate::domain::common::validate_domain_name(&ns) { + match validate_domain_name(&ns) { Ok(valid) => nameservers.push(valid), Err(e) => { eprintln!(" Invalid nameserver: {e}"); @@ -120,3 +233,15 @@ fn prompt_nameservers() -> Result> { } Ok(nameservers) } + +#[cfg(test)] +mod tests { + #[test] + fn default_period_index_falls_back_when_current_period_unavailable() { + let periods = [1_u64, 2, 3]; + let current = 5_u64; + let default_idx = periods.iter().position(|&p| p == current).unwrap_or(0); + assert_eq!(default_idx, 0); + assert_eq!(periods[default_idx], 1); + } +} diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs index fd1f1c41..6f951f9d 100644 --- a/rust/src/domain/register/steps/review.rs +++ b/rust/src/domain/register/steps/review.rs @@ -10,7 +10,8 @@ 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, + api_error, clamp_registration_periods, format_api_error, format_money, is_period_limit_error, + make_client_with_cred, parse_max_registration_period, period_label, validate_nameserver_hosts, }; use crate::environments; use crate::quote_cache; @@ -90,13 +91,54 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result 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; + Err(e) => match e { + domains_client::Error::UnexpectedResponse(resp) => { + let status = resp.status(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if is_period_limit_error(&body) + && let Some(max_years) = parse_max_registration_period(&body) + { + let rejected = state.period; + clamp_registration_periods( + &mut state.available_periods, + &mut state.period, + max_years, + ); + eprintln!( + "\n {} Period {} is not supported for this domain (maximum is {}). \ + Go back to Options to choose a different length.", + style("⚠").yellow().bold(), + rejected, + max_years + ); + return Ok(StepResult::Back); + } + let err = CliCoreError::message(format_api_error( + "domain quote", + status.as_u16(), + &status.to_string(), + &body, + request_id.as_deref(), + debug, + )); + if is_payment_error(&err) { + return handle_payment_required(ctx).await; + } + return Err(err); } - return Err(err); - } + other => { + let err = api_error("domain quote", debug, other).await; + if is_payment_error(&err) { + return handle_payment_required(ctx).await; + } + return Err(err); + } + }, }; // Extract pricing and agreement info. diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs index 81016684..0c1ea4ca 100644 --- a/rust/src/domain/register/wizard.rs +++ b/rust/src/domain/register/wizard.rs @@ -68,6 +68,8 @@ pub(crate) struct WizardState { pub privacy: bool, pub auto_renew: bool, pub nameservers: Vec, + /// Registration periods (years) priced for the selected domain. + pub available_periods: Vec, // Step 3: Contacts pub contacts: ContactsChoice, @@ -223,6 +225,7 @@ pub(crate) async fn run_wizard( if clear_domain { state.domain = None; state.available = false; + state.available_periods.clear(); } } }, diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index 4f6e174b..52f2a49a 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -180,11 +180,11 @@ pub(super) fn command() -> RuntimeCommandSpec { .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? + match super::register::bridge::offer_registration_from_suggest(&ctx, &domain_names) + .await? { - return Ok(wizard_result); + super::register::BridgeHandoff::Replace(result) => return Ok(result), + super::register::BridgeHandoff::ShowHostOutput => {} } Ok( From 609394dba8737e08240123d5d5bb5fbce5183dbd Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 1 Sep 2026 13:49:55 -0400 Subject: [PATCH 18/24] * Add interactive retry for all domain sub commands * Address Jacob's issue * Fix Go Back issue for suggest sub command --- rust/Cargo.lock | 4 - rust/Cargo.toml | 4 + rust/src/domain/agreements.rs | 121 +++-- rust/src/domain/available.rs | 11 +- rust/src/domain/common.rs | 485 ++++++++++++++++++++ rust/src/domain/get.rs | 32 +- rust/src/domain/list.rs | 33 +- rust/src/domain/mod.rs | 4 +- rust/src/domain/nameservers.rs | 14 +- rust/src/domain/operation.rs | 65 ++- rust/src/domain/purchase.rs | 65 ++- rust/src/domain/quote.rs | 55 ++- rust/src/domain/register/bridge.rs | 2 + rust/src/domain/register/mod.rs | 15 +- rust/src/domain/register/steps/discovery.rs | 26 +- rust/src/domain/register/steps/execute.rs | 10 +- rust/src/domain/register/steps/options.rs | 83 +++- rust/src/domain/register/steps/review.rs | 1 + rust/src/domain/register/wizard.rs | 54 ++- rust/src/domain/suggest.rs | 10 +- 20 files changed, 885 insertions(+), 209 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 603289c3..67520ec5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -545,8 +545,6 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fa000864b13d637997df7f5b03045b6b0a78f875d10dc583a5ecc6ec4868e5" dependencies = [ "async-trait", "base64", @@ -580,8 +578,6 @@ dependencies = [ [[package]] name = "cli-engine-macros" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2e642cc4e1aa5a6ba1ad3f52de13bf1895ee7807384a272e1bb2dcee77e6b8" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 011658a7..1407aaf3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -59,6 +59,10 @@ indicatif = "0.18.6" httpmock = "0.8" tempfile = "3" +# Local sibling checkout overrides the crates.io release during development. +[patch.crates-io] +cli-engine = { path = "../../cli-engine/cli-engine" } + [lints.rust] unsafe_code = "deny" future_incompatible = { level = "deny", priority = -1 } diff --git a/rust/src/domain/agreements.rs b/rust/src/domain/agreements.rs index 2491b5f1..ea3bba50 100644 --- a/rust/src/domain/agreements.rs +++ b/rust/src/domain/agreements.rs @@ -1,16 +1,25 @@ //! `gddy domain agreements` — the legal agreements a TLD requires (v1). +// Interactive recovery prompts write user-facing feedback to stderr. +#![allow(clippy::print_stderr)] + use cli_engine::{ - CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, + CliCoreError, CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, + Tier, }; use serde_json::json; use domains_client::types; -use super::common::{api_error, comma_joined, make_client}; +use super::common::{ + comma_joined, format_api_error, make_client, prompt_validated_tld, recoverable_tld_api_error, + resolve_tlds, +}; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; +const TLD_PROMPT: &str = "TLD whose agreements to retrieve, e.g. com (without a leading dot)"; + #[derive(Debug, Clone, clap::Args)] struct AgreementsArgs { /// TLD whose agreements to retrieve, e.g. com (repeatable). @@ -44,46 +53,82 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_json_schema::() .with_scopes(&[DOMAINS_READ]), |ctx, args: AgreementsArgs| async move { - let tlds = args.tld; let privacy = args.privacy; let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; - let resp = match client - .agreements() - .tlds(comma_joined(tlds)) - .privacy(privacy) - .send() - .await - { - Ok(r) => r, - Err(e) => return Err(api_error("retrieving legal agreements", debug, e).await), - }; - let agreements: Vec = resp - .into_inner() - .into_iter() - .map(|a| { - json!({ - "agreementKey": a.agreement_key, - "title": a.title, - "url": a.url, - "content": a.content, + let mut tlds = resolve_tlds(&ctx, args.tld, TLD_PROMPT)?; + + loop { + let resp = match client + .agreements() + .tlds(comma_joined(tlds.clone())) + .privacy(privacy) + .send() + .await + { + Ok(r) => r, + Err(domains_client::Error::UnexpectedResponse(resp)) + if ctx.is_interactive() => + { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_tld_api_error(status, &body) { + eprintln!(" {hint}"); + tlds = vec![prompt_validated_tld(TLD_PROMPT)?]; + continue; + } + return Err(CliCoreError::message(format_api_error( + "retrieving legal agreements", + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => { + return Err(super::common::api_error( + "retrieving legal agreements", + debug, + e, + ) + .await); + } + }; + + let agreements: Vec = resp + .into_inner() + .into_iter() + .map(|a| { + json!({ + "agreementKey": a.agreement_key, + "title": a.title, + "url": a.url, + "content": a.content, + }) }) - }) - .collect(); - Ok( - CommandResult::new(json!(agreements)).with_next_actions(vec![ - next_action( - "domain quote ", - "Price a registration and see the agreements for a specific domain", - ) - .with_param("domain", NextActionParam::required()), - next_action( - "domain purchase --quote-token --agree --confirm", - "Register once you have a quote", - ) - .with_param("quote-token", NextActionParam::required()), - ]), - ) + .collect(); + return Ok( + CommandResult::new(json!(agreements)).with_next_actions(vec![ + next_action( + "domain quote ", + "Price a registration and see the agreements for a specific domain", + ) + .with_param("domain", NextActionParam::required()), + next_action( + "domain purchase --quote-token --agree --confirm", + "Register once you have a quote", + ) + .with_param("quote-token", NextActionParam::required()), + ]), + ); + } }, ) } diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index e1a35adc..e2d8c55e 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -8,8 +8,8 @@ use serde_json::json; use domains_client::types; use super::common::{ - api_error, format_money, make_client, period_label, periods_from_prices, term_for_period, - validate_domain_name, + api_error, format_money, make_client, period_label, period_price_map, periods_from_prices, + resolve_domain_name, term_for_period, }; use crate::next_action::next_action; use crate::output_schema::output_schema; @@ -118,7 +118,11 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_view(view_columns()) .with_scopes(&[DOMAINS_READ]), |ctx, args: AvailableArgs| async move { - let domain = validate_domain_name(&args.domain)?; + let domain = resolve_domain_name( + &ctx, + &args.domain, + "Domain name to check (e.g. example.com)", + )?; // --check-type fast|full → v3 optimizeFor SPEED|ACCURACY. let optimize_for = match args.check_type.as_deref() { Some("fast") => Some(types::OptimizationTarget::Speed), @@ -173,6 +177,7 @@ pub(super) fn command() -> RuntimeCommandSpec { price_1yr, currency_str, periods_from_prices(&prices), + period_price_map(&prices), ) .await? { diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index cf953bb0..e8e626ba 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -4,6 +4,8 @@ use cli_engine::{CliCoreError, CommandContext, Credential, Result}; +use std::future::Future; + use crate::environments; use domains_client::types; @@ -102,6 +104,22 @@ pub(super) fn periods_from_prices(prices: &[types::TermPrice]) -> Vec { periods } +/// Indicative total registration price keyed by period years. +pub(super) fn period_price_map( + prices: &[types::TermPrice], +) -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + for term in prices { + let Some(period) = term.period.map(|p| p.get()) else { + continue; + }; + if let Some(price) = term.price.as_ref().and_then(format_money) { + map.insert(period, price); + } + } + map +} + /// Whether an API error body indicates the requested registration period exceeds /// the TLD limit. Availability pricing is indicative; quote is authoritative. pub(super) fn is_period_limit_error(body: &str) -> bool { @@ -184,6 +202,278 @@ pub(super) fn validate_domain_name(raw: &str) -> Result { Ok(trimmed.to_owned()) } +/// Normalize and validate a TLD label for `--tld` / `--tlds` flags. +/// +/// Accepts `com` or `.com` (leading dot stripped), lowercases, and validates +/// each dot-separated label as LDH. +pub(super) fn validate_tld(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("TLD is required")); + } + let stripped = trimmed.trim_start_matches('.'); + if stripped.is_empty() { + return Err(CliCoreError::message( + "TLD is required (expected something like com, not just a leading dot)", + )); + } + let lower = stripped.to_ascii_lowercase(); + let labels: Vec<&str> = lower.split('.').collect(); + if labels.iter().any(|label| !is_ldh_label(label)) { + return Err(CliCoreError::message(format!( + "{raw:?} doesn't look like a valid TLD (expected something like com or co.uk, without a leading dot)" + ))); + } + if labels + .last() + .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())) + { + return Err(CliCoreError::message(format!("{raw:?} is not a valid TLD"))); + } + Ok(lower) +} + +/// Prompt until the user enters a valid TLD or cancels. +pub(super) fn prompt_validated_tld(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_tld(input).map(|_| ()).map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_tld(&input) +} + +/// Validate `--tld` values, re-prompting interactively when input is invalid. +#[allow(clippy::print_stderr)] +pub(super) fn resolve_tlds( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + if ctx.is_interactive() { + return Ok(vec![prompt_validated_tld(prompt)?]); + } + return Err(CliCoreError::message("at least one --tld is required")); + } + match raw + .iter() + .map(|t| validate_tld(t)) + .collect::>>() + { + Ok(tlds) => Ok(tlds), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_tld(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// User-facing hint when the agreements API rejects a TLD the user can retry. +pub(super) fn recoverable_tld_api_error(status: u16, body: &str) -> Option { + if status != 422 { + return None; + } + #[derive(serde::Deserialize)] + struct CodeMessage { + #[serde(default)] + code: String, + #[serde(default)] + message: String, + } + let parsed = serde_json::from_str::(body).ok()?; + if parsed.code != "UNSUPPORTED_TLD" { + return None; + } + if parsed.message.is_empty() { + Some("that TLD is not supported; try another (e.g. com)".to_owned()) + } else { + Some(format!( + "that TLD is not supported: {} — try another (e.g. com)", + parsed.message + )) + } +} + +/// User-facing hint when an operation lookup fails and the user can retry. +pub(super) fn recoverable_operation_api_error(status: u16, _body: &str) -> Option { + if status == 404 { + Some("no operation found with that ID — check the operation ID and try again".to_owned()) + } else { + None + } +} + +/// User-facing hint when a domain lookup fails and the user can retry. +pub(super) fn recoverable_domain_lookup_api_error(status: u16, _body: &str) -> Option { + match status { + 404 => Some("that domain was not found — check the name and try again".to_owned()), + 422 => Some("that domain could not be looked up — check the name and try again".to_owned()), + _ => None, + } +} + +/// Normalize and validate an async operation ID (UUID). +pub(super) fn validate_operation_id(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("operation ID is required")); + } + uuid::Uuid::parse_str(trimmed).map_err(|_| { + CliCoreError::message(format!( + "{trimmed:?} doesn't look like a valid operation ID (expected a UUID)" + )) + })?; + Ok(trimmed.to_owned()) +} + +/// Prompt until the user enters a valid operation ID or cancels. +pub(super) fn prompt_validated_operation_id(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_operation_id(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_operation_id(&input) +} + +/// Validate an operation ID, re-prompting interactively when input is invalid. +#[allow(clippy::print_stderr)] +pub(super) fn resolve_operation_id( + ctx: &CommandContext, + raw: &str, + prompt: &str, +) -> Result { + match validate_operation_id(raw) { + Ok(id) => Ok(id), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_operation_id(prompt) + } + Err(e) => Err(e), + } +} + +/// Validate a quote token string (non-empty). +pub(super) fn validate_quote_token(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("quote token is required")); + } + Ok(trimmed.to_owned()) +} + +/// Prompt until the user enters a non-empty quote token or cancels. +pub(super) fn prompt_validated_quote_token(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_quote_token(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_quote_token(&input) +} + +/// Resolve a quote token, re-prompting interactively when missing or invalid. +#[allow(clippy::print_stderr)] +pub(super) fn resolve_quote_token( + ctx: &CommandContext, + raw: Option, + prompt: &str, +) -> Result { + match raw { + Some(token) => match validate_quote_token(&token) { + Ok(token) => Ok(token), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_quote_token(prompt) + } + Err(e) => Err(e), + }, + None if ctx.is_interactive() => prompt_validated_quote_token(prompt), + None => Err(CliCoreError::message( + "--quote-token is required.\n\ + \n Get one with `gddy domain quote `, or use the interactive \ + wizard:\n\ + \n gddy domain register\n\ + \n Then purchase with:\n\ + \n gddy domain purchase --quote-token --agree --confirm", + )), + } +} + +/// Validate optional `--tlds` filters, re-prompting interactively when invalid. +#[allow(clippy::print_stderr)] +pub(super) fn resolve_optional_tlds( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + return Ok(vec![]); + } + match raw + .iter() + .map(|t| validate_tld(t)) + .collect::>>() + { + Ok(tlds) => Ok(tlds), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_tld(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// Prompt until the user enters a valid domain name or cancels. +pub(super) fn prompt_validated_domain_name(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .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) +} + +/// Validate a domain name, re-prompting interactively when input is invalid. +/// +/// Used by leaf commands whose positional `DOMAIN` arg may be supplied via +/// cli-engine's missing-arg recovery (which does not validate format). +#[allow(clippy::print_stderr)] // interactive user-facing feedback, not diagnostic logging +pub(super) fn resolve_domain_name(ctx: &CommandContext, raw: &str, prompt: &str) -> Result { + match validate_domain_name(raw) { + Ok(domain) => Ok(domain), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_domain_name(prompt) + } + Err(e) => Err(e), + } +} + /// Validate every value of a repeatable `--nameserver`-style flag as a /// domain-shaped hostname, wrapping [`validate_domain_name`]'s generic error /// with the flag's own name — used by both `quote` (the registration @@ -198,6 +488,141 @@ pub(super) fn validate_nameserver_hosts(raw: Vec) -> Result> .collect() } +/// Prompt until the user enters a valid nameserver host or cancels. +pub(super) fn prompt_validated_nameserver(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_domain_name(input) + .map(|_| ()) + .map_err(|e| format!("--nameserver {e}")) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_domain_name(&input) +} + +/// Resolve `--nameserver` hosts, re-prompting interactively when missing or invalid. +#[allow(clippy::print_stderr)] +pub(super) fn resolve_nameserver_hosts( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + if ctx.is_interactive() { + return Ok(vec![prompt_validated_nameserver(prompt)?]); + } + return Err(CliCoreError::message( + "at least one --nameserver is required", + )); + } + match validate_nameserver_hosts(raw) { + Ok(hosts) => Ok(hosts), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_nameserver(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// Re-run a domain-scoped API call, re-prompting for the domain on recoverable errors. +#[allow(clippy::print_stderr)] +pub(super) async fn fetch_with_domain_retry( + ctx: &CommandContext, + initial_domain: String, + prompt: &str, + action: &str, + debug: bool, + fetch: F, +) -> Result +where + F: Fn(domains_client::Client, String) -> Fut, + Fut: Future>>, +{ + let client = make_client(ctx).await?; + let mut domain = initial_domain; + loop { + match fetch(client.clone(), domain.clone()).await { + Ok(value) => return Ok(value), + Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_domain_lookup_api_error(status, &body) { + eprintln!(" {hint}"); + domain = prompt_validated_domain_name(prompt)?; + continue; + } + return Err(CliCoreError::message(format_api_error( + action, + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => return Err(api_error(action, debug, e).await), + } + } +} + +/// Re-run an operation lookup, re-prompting for the operation ID on recoverable errors. +#[allow(clippy::print_stderr)] +pub(super) async fn fetch_with_operation_retry( + ctx: &CommandContext, + initial_id: String, + prompt: &str, + action: &str, + debug: bool, + fetch: F, +) -> Result +where + F: Fn(domains_client::Client, String) -> Fut, + Fut: Future>>, +{ + let client = make_client(ctx).await?; + let mut operation_id = initial_id; + loop { + match fetch(client.clone(), operation_id.clone()).await { + Ok(value) => return Ok(value), + Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_operation_api_error(status, &body) { + eprintln!(" {hint}"); + operation_id = prompt_validated_operation_id(prompt)?; + continue; + } + return Err(CliCoreError::message(format_api_error( + action, + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => return Err(api_error(action, debug, e).await), + } + } +} + /// A single RFC 1035/1123 "LDH label": 1-63 bytes, alphanumeric, interior /// hyphens only (not leading/trailing). fn is_ldh_label(label: &str) -> bool { @@ -558,6 +983,22 @@ mod tests { assert_eq!(periods_from_prices(&prices), vec![1, 2, 3]); } + #[test] + fn period_price_map_formats_indicative_totals() { + use domains_client::types; + + let prices = vec![types::TermPrice { + period: std::num::NonZeroU64::new(2), + price: Some(types::SimpleMoney { + value: Some(6098), + currency_code: Some(types::CurrencyCode("USD".to_string())), + }), + ..Default::default() + }]; + let map = period_price_map(&prices); + assert_eq!(map.get(&2).map(String::as_str), Some("60.98")); + } + #[test] fn parse_max_registration_period_reads_api_error_text() { let body = r#"{"details":[{"description":"period 5 is not currently supported; maximum is 3 years"}]}"#; @@ -711,6 +1152,50 @@ mod tests { assert!(validate_domain_name("example.123").is_err()); } + #[test] + fn validate_tld_strips_leading_dot_and_lowercases() { + assert_eq!(validate_tld(".org").expect("valid"), "org"); + assert_eq!(validate_tld("COM").expect("valid"), "com"); + assert_eq!(validate_tld("co.uk").expect("valid"), "co.uk"); + } + + #[test] + fn validate_tld_rejects_empty_or_dot_only() { + assert!(validate_tld("").is_err()); + assert!(validate_tld(".").is_err()); + assert!(validate_tld(" ").is_err()); + } + + #[test] + fn recoverable_tld_api_error_matches_unsupported_tld() { + let hint = recoverable_tld_api_error( + 422, + r#"{"code":"UNSUPPORTED_TLD","message":"The specified TLD is currently unsupported"}"#, + ) + .expect("recoverable"); + assert!(hint.contains("not supported")); + } + + #[test] + fn recoverable_tld_api_error_ignores_other_codes() { + assert!(recoverable_tld_api_error(422, r#"{"code":"OTHER","message":"nope"}"#).is_none()); + assert!(recoverable_tld_api_error(404, r#"{"code":"UNSUPPORTED_TLD"}"#).is_none()); + } + + #[test] + fn validate_operation_id_accepts_uuid() { + assert_eq!( + validate_operation_id("550e8400-e29b-41d4-a716-446655440000").expect("valid"), + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn validate_operation_id_rejects_garbage() { + assert!(validate_operation_id("not-a-uuid").is_err()); + assert!(validate_operation_id("").is_err()); + } + #[test] fn validate_domain_name_rejects_leading_or_trailing_hyphen_labels() { assert!(validate_domain_name("-example.com").is_err()); diff --git a/rust/src/domain/get.rs b/rust/src/domain/get.rs index 7c5dd623..ef7938ac 100644 --- a/rust/src/domain/get.rs +++ b/rust/src/domain/get.rs @@ -6,10 +6,12 @@ use cli_engine::{ use domains_client::types; -use super::common::{api_error, make_client, validate_domain_name}; +use super::common::{fetch_with_domain_retry, resolve_domain_name}; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; +const DOMAIN_PROMPT: &str = "Domain to look up (e.g. example.com)"; + #[derive(Debug, Clone, clap::Args)] struct GetArgs { /// Domain to look up (must be in your account), e.g. example.com. @@ -30,18 +32,24 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_json_schema::() .with_scopes(&[DOMAINS_READ]), |ctx, args: GetArgs| async move { - let domain = validate_domain_name(&args.domain)?; let debug = !ctx.middleware.debug.is_empty(); - let client = make_client(&ctx).await?; - let detail = match client - .get_domain() - .domain_name(domain.as_str()) - .send() - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("retrieving domain details", debug, e).await), - }; + let domain = resolve_domain_name(&ctx, &args.domain, DOMAIN_PROMPT)?; + let detail = fetch_with_domain_retry( + &ctx, + domain, + DOMAIN_PROMPT, + "retrieving domain details", + debug, + |client, domain| async move { + client + .get_domain() + .domain_name(domain.as_str()) + .send() + .await + .map(|r| r.into_inner()) + }, + ) + .await?; let value = serde_json::to_value(&detail).map_err(|e| { CliCoreError::message(format!("failed to serialize domain details: {e}")) })?; diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index 61581e8d..20ec4d28 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -1,5 +1,8 @@ //! `gddy domain list` — list the domains in the account (v3). +// Interactive status recovery prompts write user-facing feedback to stderr. +#![allow(clippy::print_stderr)] + use cli_engine::{ CliCoreError, CommandResult, CommandSpec, NextActionParam, PaginationConfig, Result, RuntimeCommandSpec, Tier, @@ -54,6 +57,34 @@ fn wants_visible_only(statuses: &[String], show_hidden: bool) -> bool { statuses.is_empty() && !show_hidden } +/// Prompt until the user enters a valid `--status` value or cancels. +fn prompt_validated_status() -> Result> { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt("Domain status filter (e.g. ACTIVE)") + .validate_with(|input: &String| -> std::result::Result<(), String> { + parse_statuses(std::slice::from_ref(input)) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + parse_statuses(&[input]) +} + +/// Validate `--status` values, re-prompting interactively when invalid. +fn resolve_statuses(ctx: &cli_engine::CommandContext, raw: Vec) -> Result> { + match parse_statuses(&raw) { + Ok(statuses) => Ok(statuses), + Err(e) if ctx.is_interactive() && !raw.is_empty() => { + eprintln!(" {e}"); + prompt_validated_status() + } + Err(e) => Err(e), + } +} + #[derive(Debug, Clone, clap::Args)] struct ListArgs { /// Only domains with this status, e.g. ACTIVE (repeatable). @@ -225,7 +256,7 @@ pub(super) fn command() -> RuntimeCommandSpec { }), |ctx, args: ListArgs| async move { let debug = !ctx.middleware.debug.is_empty(); - let statuses = parse_statuses(&args.status)?; + let statuses = resolve_statuses(&ctx, args.status)?; let show_hidden = args.show_hidden; let visible_only = wants_visible_only(&statuses, show_hidden); let client = make_client(&ctx).await?; diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index 7ea28fd7..f261734e 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -50,7 +50,7 @@ pub fn module() -> Module { • 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\ + • operation — check on an async operation (e.g. a pending purchase)\n\ \n\ Reads need the `domains.domain:read` scope; purchase/register also\n\ need `domains.domain:create`, and `nameservers set` needs\n\ @@ -67,7 +67,7 @@ pub fn module() -> Module { .with_command(register::command()) .with_group(nameservers::group()) .with_group(contacts::group()) - .with_group(operation::group()) + .with_command(operation::command()) }) .with_guides_from_markdown([ ( diff --git a/rust/src/domain/nameservers.rs b/rust/src/domain/nameservers.rs index 227fe5ca..3edd6f99 100644 --- a/rust/src/domain/nameservers.rs +++ b/rust/src/domain/nameservers.rs @@ -8,7 +8,7 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, make_client, validate_domain_name, validate_nameserver_hosts}; +use super::common::{api_error, make_client, resolve_domain_name, resolve_nameserver_hosts}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_NAMESERVER_UPDATE; @@ -55,8 +55,16 @@ pub(super) fn group() -> RuntimeGroupSpec { .with_output_schema::() .with_scopes(&[DOMAINS_NAMESERVER_UPDATE]), |ctx, args: NameserversSetArgs| async move { - let domain = validate_domain_name(&args.domain)?; - let hosts = validate_nameserver_hosts(args.nameserver)?; + let domain = resolve_domain_name( + &ctx, + &args.domain, + "Domain whose nameservers to replace (e.g. example.com)", + )?; + let hosts = resolve_nameserver_hosts( + &ctx, + args.nameserver, + "Nameserver host (e.g. ns1.example.com)", + )?; let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; diff --git a/rust/src/domain/operation.rs b/rust/src/domain/operation.rs index 3b15ea91..c44179ff 100644 --- a/rust/src/domain/operation.rs +++ b/rust/src/domain/operation.rs @@ -1,23 +1,24 @@ -//! `gddy domain operation status` — poll an async domain operation (v3). +//! `gddy domain operation` — poll an async domain operation (v3). //! //! Every async domain mutation (register, nameserver updates, and eventually //! renew/transfer) returns an `operationId` that can be re-checked here via the //! same `GET /v3/domains/operations/{operationId}` endpoint `purchase`'s //! bounded poll loop already uses internally. -use cli_engine::{ - CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, - Tier, -}; +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::json; use domains_client::types; -use super::common::{api_error, format_operation_error, is_terminal_status, make_client}; +use super::common::{ + fetch_with_operation_retry, format_operation_error, is_terminal_status, resolve_operation_id, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; +const OPERATION_ID_PROMPT: &str = "Operation ID returned by an async domain mutation (UUID)"; + output_schema!(DomainOperationStatusResult { "operationId": "string"; "type": "string"; @@ -30,16 +31,16 @@ output_schema!(DomainOperationStatusResult { }); #[derive(Debug, Clone, clap::Args)] -struct OperationStatusArgs { +struct OperationArgs { /// The operationId returned by an async domain mutation. #[arg(value_name = "OPERATION_ID")] operation_id: String, } -fn status_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "status", +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "operation", "Check the status of an async domain operation", ) .with_long( @@ -53,20 +54,26 @@ fn status_command() -> RuntimeCommandSpec { .with_default_fields("operationId,type,domain,status,orderId,expiresAt,error") .with_output_schema::() .with_scopes(&[DOMAINS_READ]), - |ctx, args: OperationStatusArgs| async move { - let operation_id = args.operation_id; + |ctx, args: OperationArgs| async move { let debug = !ctx.middleware.debug.is_empty(); + let operation_id = resolve_operation_id(&ctx, &args.operation_id, OPERATION_ID_PROMPT)?; - let client = make_client(&ctx).await?; - let op = match client - .get_operation() - .operation_id(types::Uuid(operation_id.clone())) - .send() - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("checking domain operation status", debug, e).await), - }; + let op = fetch_with_operation_retry( + &ctx, + operation_id, + OPERATION_ID_PROMPT, + "checking domain operation status", + debug, + |client, id| async move { + client + .get_operation() + .operation_id(types::Uuid(id)) + .send() + .await + .map(|r| r.into_inner()) + }, + ) + .await?; let status = op .status @@ -78,7 +85,7 @@ fn status_command() -> RuntimeCommandSpec { .operation_id .as_ref() .map(|id| id.to_string()) - .unwrap_or(operation_id); + .unwrap_or_default(); let op_type = op.type_.as_ref().map(|t| t.to_string()).unwrap_or_default(); let mut result = json!({ @@ -120,7 +127,7 @@ fn status_command() -> RuntimeCommandSpec { } else { actions.push( next_action( - "domain operation status ", + "domain operation ", "Re-check whether the operation has finished", ) .with_param("operation-id", NextActionParam::value(op_id)), @@ -131,11 +138,3 @@ fn status_command() -> RuntimeCommandSpec { }, ) } - -pub(super) fn group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new( - "operation", - "Check the status of async domain operations", - )) - .with_command(status_command()) -} diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 03b6f0f1..bc508429 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -1,5 +1,7 @@ //! `gddy domain purchase` — register a domain by accepting a cached quote (v3). +#![allow(clippy::print_stderr)] // interactive quote-token recovery writes to stderr + use cli_engine::{ CliCoreError, CommandResult, CommandSpec, Credential, NextActionParam, Result, RuntimeCommandSpec, Tier, @@ -8,7 +10,10 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, format_operation_error, is_terminal_status, make_client_with_cred}; +use super::common::{ + api_error, format_operation_error, is_terminal_status, make_client_with_cred, + prompt_validated_quote_token, resolve_quote_token, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::quote_cache; @@ -145,8 +150,10 @@ fn purchase_consent_types( #[derive(Debug, Clone, clap::Args)] struct PurchaseArgs { /// The quote token from `gddy domain quote` (locks the price + settings). + /// Optional at the clap layer so interactive mode can give a clear recovery + /// path instead of prompting for an opaque UUID. #[arg(long = "quote-token", value_name = "TOKEN")] - quote_token: String, + quote_token: Option, /// Consent to the quote's legal agreements (run without it to list them; /// review with `gddy guide domain-purchase`). @@ -189,7 +196,9 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_output_schema::() .with_scopes(&[DOMAINS_READ, DOMAINS_CREATE]), |ctx, args: PurchaseArgs| async move { - let quote_token = args.quote_token; + const QUOTE_TOKEN_PROMPT: &str = "Quote token from `gddy domain quote`"; + + let mut quote_token = resolve_quote_token(&ctx, args.quote_token, QUOTE_TOKEN_PROMPT)?; let agree = args.agree; let confirm = args.confirm; let debug = !ctx.middleware.debug.is_empty(); @@ -206,28 +215,42 @@ pub(super) fn command() -> RuntimeCommandSpec { // Load the quote the user reviewed. Read-only: the entry is only // removed once the registration succeeds, so an un-`--agree`d run or // a failed charge leaves the quote reusable. - let cached = match quote_cache::get("e_token) { - quote_cache::Lookup::Found(q) => *q, - quote_cache::Lookup::Expired => { - return Err(CliCoreError::message( - "that quote has expired (quotes last ~10 minutes). Re-run \ + let cached = loop { + match quote_cache::get("e_token) { + quote_cache::Lookup::Found(q) => break *q, + quote_cache::Lookup::Expired if ctx.is_interactive() => { + eprintln!( + " that quote has expired (quotes last ~10 minutes); enter a fresh token from `gddy domain quote`" + ); + quote_token = prompt_validated_quote_token(QUOTE_TOKEN_PROMPT)?; + } + quote_cache::Lookup::Missing if ctx.is_interactive() => { + eprintln!( + " no cached quote for that token; run `gddy domain quote ` on this machine first" + ); + quote_token = prompt_validated_quote_token(QUOTE_TOKEN_PROMPT)?; + } + quote_cache::Lookup::Expired => { + return Err(CliCoreError::message( + "that quote has expired (quotes last ~10 minutes). Re-run \ `gddy domain quote ` for a fresh quote and token.", - )); - } - quote_cache::Lookup::Missing => { - return Err(CliCoreError::message( - "no cached quote for that token. Run `gddy domain quote ` \ + )); + } + quote_cache::Lookup::Missing => { + return Err(CliCoreError::message( + "no cached quote for that token. Run `gddy domain quote ` \ first — quotes are cached locally, so quote and purchase must run \ on the same machine within the token's ~10-minute lifetime.", - )); - } - quote_cache::Lookup::NoConfigDir => { - return Err(CliCoreError::message( - "could not locate a config directory to read the quote cache from. \ + )); + } + quote_cache::Lookup::NoConfigDir => { + return Err(CliCoreError::message( + "could not locate a config directory to read the quote cache from. \ `domain purchase` needs the local quote written by `domain quote`; \ ensure a home/config directory is available (e.g. set HOME or \ XDG_CONFIG_HOME) and re-run `gddy domain quote `.", - )); + )); + } } }; let domain = cached.domain.clone(); @@ -390,7 +413,7 @@ pub(super) fn command() -> RuntimeCommandSpec { %status, operation_id = %op, "registration still in progress after polling; check later with \ - `gddy domain operation status {op}`" + `gddy domain operation {op}`" ); } @@ -418,7 +441,7 @@ pub(super) fn command() -> RuntimeCommandSpec { if let Some(op) = &operation_id { actions.push( next_action( - "domain operation status ", + "domain operation ", "Check whether registration has finished since polling gave up", ) .with_param("operation-id", NextActionParam::value(op.to_string())), diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 4138a43a..a3f270bd 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -9,8 +9,8 @@ use serde_json::json; use domains_client::types; use super::common::{ - api_error, format_money, make_client, period_label, validate_domain_name, - validate_nameserver_hosts, + fetch_with_domain_retry, format_money, period_label, resolve_domain_name, + resolve_nameserver_hosts, }; use crate::next_action::next_action; use crate::output_schema::output_schema; @@ -305,11 +305,19 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_view(view_columns()) .with_scopes(&[DOMAINS_READ]), |ctx, args: QuoteArgs| async move { - let domain = validate_domain_name(&args.domain)?; + let domain = resolve_domain_name( + &ctx, + &args.domain, + "Domain name to quote (e.g. example.com)", + )?; let period = args.period; let privacy = args.privacy; let renew_auto = !args.no_renew; - let name_servers = validate_nameserver_hosts(args.nameserver)?; + let name_servers = resolve_nameserver_hosts( + &ctx, + args.nameserver, + "Custom nameserver host (e.g. ns1.example.com)", + )?; let debug = !ctx.middleware.debug.is_empty(); let period_nz = std::num::NonZeroU64::new(period).expect("clap value_parser enforces period >= 1"); @@ -317,7 +325,6 @@ pub(super) fn command() -> RuntimeCommandSpec { // Resolve auth first (fail closed before reading local config), then // build the profile. The token is bound to a hash of this quoted // request, so what's quoted here is what `purchase` later registers. - let client = make_client(&ctx).await?; let profile = build_profile(privacy, renew_auto, &name_servers)?; // Cache the exact profile we quote with: the token binds a hash of the // domain/price/profile, so `purchase` must re-send this verbatim or the @@ -330,20 +337,30 @@ pub(super) fn command() -> RuntimeCommandSpec { )) })?; - let quote = match client - .quote_domain_registration() - .body(types::QuoteDomainRegistrationBody { - domain: domain.clone(), - period: period_nz, - profile: Some(profile), - profile_id: None, - }) - .send() - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("quoting registration", debug, e).await), - }; + let quote = fetch_with_domain_retry( + &ctx, + domain.clone(), + "Domain name to quote (e.g. example.com)", + "quoting registration", + debug, + |client, domain| { + let profile = profile.clone(); + async move { + client + .quote_domain_registration() + .body(types::QuoteDomainRegistrationBody { + domain, + period: period_nz, + profile: Some(profile), + profile_id: None, + }) + .send() + .await + .map(|r| r.into_inner()) + } + }, + ) + .await?; let view = quote_to_json("e, &domain); diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs index fb636789..55c62c56 100644 --- a/rust/src/domain/register/bridge.rs +++ b/rust/src/domain/register/bridge.rs @@ -16,6 +16,7 @@ pub(crate) async fn offer_registration_from_available( price: Option, currency: Option, available_periods: Vec, + period_prices: std::collections::BTreeMap, ) -> Result { if !ctx.is_interactive() { return Ok(BridgeHandoff::ShowHostOutput); @@ -37,6 +38,7 @@ pub(crate) async fn offer_registration_from_available( state.price = price.clone(); state.currency = currency.clone(); state.available_periods = available_periods.clone(); + state.period_prices = period_prices.clone(); match super::launch_wizard(ctx, state, 1).await? { WizardExit::Completed(result) => { diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs index 0e98f2d3..263eef28 100644 --- a/rust/src/domain/register/mod.rs +++ b/rust/src/domain/register/mod.rs @@ -10,7 +10,7 @@ use cli_engine::{ }; use serde_json::json; -use crate::domain::common::{is_terminal_status, validate_domain_name}; +use crate::domain::common::{is_terminal_status, resolve_domain_name, validate_domain_name}; use crate::next_action::{next_action, required_value}; use crate::output_schema::output_schema; use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; @@ -120,7 +120,14 @@ async fn run_interactive(ctx: CommandContext, args: RegisterArgs) -> Result Some(resolve_domain_name( + &ctx, + &d, + "Enter domain name to register (e.g. example.com)", + )?), + None => None, + }; let state = WizardState::new() .with_domain(domain) @@ -288,7 +295,7 @@ fn build_result(state: &WizardState) -> Result { { actions.push( next_action( - "domain operation status ", + "domain operation ", "Check whether registration has finished since polling gave up", ) .with_param("operation-id", required_value(op.clone())), @@ -413,7 +420,7 @@ mod tests { .metadata .next_actions .iter() - .find(|a| a.command.contains("operation status")) + .find(|a| a.command.contains("domain operation")) .expect("pending registration should suggest operation status"); assert_eq!( status_action diff --git a/rust/src/domain/register/steps/discovery.rs b/rust/src/domain/register/steps/discovery.rs index 34f3815d..70bd5429 100644 --- a/rust/src/domain/register/steps/discovery.rs +++ b/rust/src/domain/register/steps/discovery.rs @@ -3,10 +3,11 @@ use cli_engine::{CliCoreError, Result}; use console::style; -use dialoguer::{Input, Select}; +use dialoguer::Select; use crate::domain::common::{ - api_error, make_client_with_cred, periods_from_prices, term_for_period, validate_domain_name, + api_error, make_client_with_cred, period_price_map, periods_from_prices, + prompt_validated_domain_name, term_for_period, }; use crate::retry::with_retry; @@ -24,7 +25,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result d.clone(), - None => prompt_domain_name()?, + None => prompt_validated_domain_name("Enter domain name to register")?, }; let client = make_client_with_cred(&ctx.env, &ctx.credential)?; @@ -49,6 +50,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result 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, @@ -153,6 +142,7 @@ fn select_from_suggestions( state.domain = None; state.available = false; state.available_periods.clear(); + state.period_prices.clear(); return Ok(StepResult::Back); } @@ -160,6 +150,7 @@ fn select_from_suggestions( state.domain = Some(chosen.domain.clone()); state.available = true; state.available_periods = chosen.available_periods.clone(); + state.period_prices = chosen.period_prices.clone(); state.price = chosen.price.clone(); state.currency = chosen.currency.clone(); eprintln!( @@ -183,6 +174,7 @@ fn prompt_retry_or_cancel(state: &mut WizardState) -> Result { state.domain = None; state.available = false; state.available_periods.clear(); + state.period_prices.clear(); Ok(StepResult::Back) } else { Ok(StepResult::Cancel) @@ -194,6 +186,7 @@ struct SuggestionEntry { price: Option, currency: Option, available_periods: Vec, + period_prices: std::collections::BTreeMap, } /// Extract unique suggestions from raw API items, capped at `max`. @@ -226,6 +219,7 @@ fn collect_suggestions( price, currency, available_periods: periods_from_prices(prices), + period_prices: period_price_map(prices), }); } all diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs index 9f792b1c..f4167e14 100644 --- a/rust/src/domain/register/steps/execute.rs +++ b/rust/src/domain/register/steps/execute.rs @@ -197,10 +197,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result Result = state .available_periods .iter() - .map(|p| period_label(*p)) + .map(|p| period_option_label(*p, state)) .collect(); let default_idx = state @@ -36,12 +39,19 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result Result 0 { - "↩ Go back to change domain" - } else { - "↩ Go back to discovery" - }; - let choices = vec!["Continue", back_label]; + let choices = vec!["Continue", back_label.as_str()]; let selection = Select::new() .items(&choices) .default(0) @@ -131,7 +136,9 @@ async fn ensure_available_periods(state: &mut WizardState, ctx: &StepContext) -> ))); } - state.available_periods = periods_from_prices(&availability.prices.unwrap_or_default()); + let prices = availability.prices.unwrap_or_default(); + state.available_periods = periods_from_prices(&prices); + state.period_prices = period_price_map(&prices); if state.available_periods.is_empty() { state.available_periods = vec![1]; } @@ -139,6 +146,25 @@ async fn ensure_available_periods(state: &mut WizardState, ctx: &StepContext) -> Ok(()) } +/// Back-navigation label for the options step, depending on wizard entry point. +fn options_back_label(ctx: &StepContext) -> String { + if ctx.wizard_start_at > 0 { + "↩ Go back to change domain".to_string() + } else { + "↩ Go back to discovery".to_string() + } +} + +/// Label for a period option, including indicative total price when known. +fn period_option_label(period: u64, state: &WizardState) -> String { + let base = period_label(period); + match (state.period_prices.get(&period), state.currency.as_deref()) { + (Some(price), Some(currency)) => format!("{base} — {price} {currency}"), + (Some(price), None) => format!("{base} — {price}"), + _ => base, + } +} + /// Availability pricing is indicative; probe the quote endpoint with the longest /// offered period to learn the TLD's authoritative maximum. async fn refine_periods_with_quote_limit(state: &mut WizardState, ctx: &StepContext) -> Result<()> { @@ -184,20 +210,12 @@ async fn refine_periods_with_quote_limit(state: &mut WizardState, ctx: &StepCont if is_period_limit_error(&body) && let Some(max_years) = parse_max_registration_period(&body) { - let before = state.available_periods.len(); clamp_registration_periods( &mut state.available_periods, &mut state.period, max_years, ); - if state.available_periods.len() < before { - eprintln!( - " {} This TLD supports up to {} years; longer indicative \ - prices from availability were removed.", - style("ℹ").cyan().bold(), - max_years - ); - } + state.period_prices.retain(|years, _| *years <= max_years); } Ok(()) } @@ -236,6 +254,9 @@ fn prompt_nameservers() -> Result> { #[cfg(test)] mod tests { + use super::*; + use crate::domain::register::wizard::WizardState; + #[test] fn default_period_index_falls_back_when_current_period_unavailable() { let periods = [1_u64, 2, 3]; @@ -244,4 +265,26 @@ mod tests { assert_eq!(default_idx, 0); assert_eq!(periods[default_idx], 1); } + + #[test] + fn options_back_label_reflects_entry_point() { + let mut ctx = StepContext { + credential: cli_engine::Credential::default(), + env: "test".to_string(), + debug: false, + wizard_start_at: 0, + }; + assert_eq!(options_back_label(&ctx), "↩ Go back to discovery"); + ctx.wizard_start_at = 1; + assert_eq!(options_back_label(&ctx), "↩ Go back to change domain"); + } + + #[test] + fn period_option_label_includes_price_when_known() { + let mut state = WizardState::new(); + state.currency = Some("USD".to_string()); + state.period_prices.insert(3, "120.97".to_string()); + assert_eq!(period_option_label(3, &state), "3 years — 120.97 USD"); + assert_eq!(period_option_label(1, &state), "1 year"); + } } diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs index 6f951f9d..e5546aa1 100644 --- a/rust/src/domain/register/steps/review.rs +++ b/rust/src/domain/register/steps/review.rs @@ -109,6 +109,7 @@ pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result BackTransition { if current == 0 && start_at > 0 { return BackTransition::ExitToBridge; } if current == start_at { if start_at > 0 { - let step = start_at - 1; - BackTransition::GoTo { - step, - clear_domain: step == 0, - } - } else { - BackTransition::ExitToBridge - } - } else { - let step = current.saturating_sub(1); - BackTransition::GoTo { - step, - clear_domain: step == 0, + return BackTransition::ExitToBridge; } + // Full `domain register` flow at Discovery — re-run discovery (e.g. "try + // a different domain") instead of exiting the wizard. + return BackTransition::GoTo { + step: 0, + clear_domain: true, + }; + } + let step = current.saturating_sub(1); + BackTransition::GoTo { + step, + clear_domain: step == 0, } } @@ -70,6 +73,8 @@ pub(crate) struct WizardState { pub nameservers: Vec, /// Registration periods (years) priced for the selected domain. pub available_periods: Vec, + /// Indicative total price per period (years → formatted amount). + pub period_prices: std::collections::BTreeMap, // Step 3: Contacts pub contacts: ContactsChoice, @@ -226,6 +231,7 @@ pub(crate) async fn run_wizard( state.domain = None; state.available = false; state.available_periods.clear(); + state.period_prices.clear(); } } }, @@ -298,13 +304,10 @@ mod tests { #[test] fn back_transition_from_bridge_entry_steps() { - // suggest/available enter at Options — back goes to Discovery, not bridge. + // suggest/available enter at Options — back returns to the host bridge UI. assert!(matches!( back_transition(1, 1), - BackTransition::GoTo { - step: 0, - clear_domain: true - } + BackTransition::ExitToBridge )); // Discovery after bridge entry — return to bridge UI. assert!(matches!( @@ -312,13 +315,10 @@ mod tests { BackTransition::ExitToBridge )); - // quote enters at Review — back goes to Contacts, not bridge. + // quote enters at Review — back returns to the host bridge UI. assert!(matches!( back_transition(3, 3), - BackTransition::GoTo { - step: 2, - clear_domain: false - } + BackTransition::ExitToBridge )); // Mid-flow back within full wizard. assert!(matches!( @@ -328,6 +328,14 @@ mod tests { clear_domain: false } )); + // Full register at Discovery — restart discovery, don't exit. + assert!(matches!( + back_transition(0, 0), + BackTransition::GoTo { + step: 0, + clear_domain: true + } + )); } #[test] diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index 52f2a49a..625baf4e 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -7,7 +7,9 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, comma_joined, format_money, make_client, term_for_period}; +use super::common::{ + api_error, comma_joined, format_money, make_client, resolve_optional_tlds, term_for_period, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -148,7 +150,11 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_scopes(&[DOMAINS_READ]), |ctx, args: SuggestArgs| async move { let query = args.query; - let tlds = args.tlds; + let tlds = resolve_optional_tlds( + &ctx, + args.tlds, + "TLD filter (e.g. com, without a leading dot)", + )?; let limit = args.limit.and_then(nonzero); let length_min = args.length_min; let length_max = args.length_max; From e27fe6b3d671579aefa1ef1b9000bfb116270765 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 1 Sep 2026 13:56:05 -0400 Subject: [PATCH 19/24] remove the patch.io --- rust/Cargo.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1407aaf3..011658a7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -59,10 +59,6 @@ indicatif = "0.18.6" httpmock = "0.8" tempfile = "3" -# Local sibling checkout overrides the crates.io release during development. -[patch.crates-io] -cli-engine = { path = "../../cli-engine/cli-engine" } - [lints.rust] unsafe_code = "deny" future_incompatible = { level = "deny", priority = -1 } From aca7432148d3c3399bba51d036336778c88bf820 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 1 Sep 2026 15:20:50 -0400 Subject: [PATCH 20/24] test(domain): update auth test for flattened operation command Use `domain operation ` instead of the removed `status` subcommand and cover `domain register` in the fail-closed auth matrix. Co-authored-by: Cursor --- rust/src/domain/mod.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index f261734e..6c20cdba 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -94,7 +94,7 @@ mod tests { #[tokio::test] async fn domain_commands_require_auth() { const AUTH_FAILURE_EXIT: i32 = 2; - let cases: [&[&str]; 9] = [ + let cases: [&[&str]; 10] = [ &["gddy", "domain", "list", "--output", "json"], &["gddy", "domain", "get", "example.com", "--output", "json"], &[ @@ -129,6 +129,13 @@ mod tests { "--output", "json", ], + &[ + "gddy", + "domain", + "register", + "--output", + "json", + ], &[ "gddy", "domain", @@ -144,7 +151,6 @@ mod tests { "gddy", "domain", "operation", - "status", "dummy-op-id", "--output", "json", From 3c4e348909bfabf8d7ad0d43d2cb69a0bf956095 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 1 Sep 2026 15:27:11 -0400 Subject: [PATCH 21/24] Fix format issue --- rust/src/domain/mod.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index 6c20cdba..a8385d12 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -129,13 +129,7 @@ mod tests { "--output", "json", ], - &[ - "gddy", - "domain", - "register", - "--output", - "json", - ], + &["gddy", "domain", "register", "--output", "json"], &[ "gddy", "domain", From 62f081582b0c9c40ef1e3e7a3c377be73644a2b6 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 1 Sep 2026 15:36:01 -0400 Subject: [PATCH 22/24] refactor(domain): split common.rs to satisfy 1000-line CI limit Move client, pricing, validation, interactive recovery, errors, and operation helpers into domain/common/ submodules while preserving the existing common::* import surface for domain commands. Co-authored-by: Cursor --- rust/src/domain/common.rs | 1353 ------------------------- rust/src/domain/common/client.rs | 58 ++ rust/src/domain/common/errors.rs | 313 ++++++ rust/src/domain/common/interactive.rs | 384 +++++++ rust/src/domain/common/mod.rs | 73 ++ rust/src/domain/common/operation.rs | 75 ++ rust/src/domain/common/pricing.rs | 194 ++++ rust/src/domain/common/validation.rs | 253 +++++ 8 files changed, 1350 insertions(+), 1353 deletions(-) delete mode 100644 rust/src/domain/common.rs create mode 100644 rust/src/domain/common/client.rs create mode 100644 rust/src/domain/common/errors.rs create mode 100644 rust/src/domain/common/interactive.rs create mode 100644 rust/src/domain/common/mod.rs create mode 100644 rust/src/domain/common/operation.rs create mode 100644 rust/src/domain/common/pricing.rs create mode 100644 rust/src/domain/common/validation.rs diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs deleted file mode 100644 index e8e626ba..00000000 --- a/rust/src/domain/common.rs +++ /dev/null @@ -1,1353 +0,0 @@ -//! Shared helpers for the `domain` command group: the authenticated Domains API -//! client, money formatting, argument helpers, and API-error rendering. Each -//! `gddy domain` subcommand lives in its own sibling module and draws from here. - -use cli_engine::{CliCoreError, CommandContext, Credential, Result}; - -use std::future::Future; - -use crate::environments; - -use domains_client::types; - -const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); - -/// Bridges domains-client's request/response observations into cli-engine's -/// `--debug transport` logger. domains-client defines the `TransportObserver` -/// extension point itself and has no compile-time dependency on cli-engine — -/// this crate *pushes* the logging behavior in, rather than domains-client -/// *pulling* it from the engine. -struct CliEngineTransportObserver; - -impl domains_client::TransportObserver for CliEngineTransportObserver { - fn on_request(&self, request: &reqwest::Request) { - cli_engine::transport::debug_log_reqwest_request(request); - } - - fn on_response(&self, status: reqwest::StatusCode, headers: &reqwest::header::HeaderMap) { - cli_engine::transport::debug_log_reqwest_response(status, headers, &[]); - } -} - -static TRANSPORT_OBSERVER_INIT: std::sync::Once = std::sync::Once::new(); - -fn ensure_transport_observer_registered() { - TRANSPORT_OBSERVER_INIT.call_once(|| { - domains_client::set_transport_observer(Some(std::sync::Arc::new( - CliEngineTransportObserver, - ))); - }); -} - -/// The ISO-4217 minor-unit exponent for a currency — how many implied decimal -/// places a [`types::SimpleMoney`] `value` carries (the v3 spec defers money -/// formatting to ISO 4217). Sourced from the `iso_currency` crate's maintained -/// ISO 4217 dataset rather than a hand table, so it stays complete and correct -/// (JPY → 0, USD → 2, KWD → 3, CLF → 4, …). -/// -/// Falls back to 2 for an unrecognized code and for the codes ISO marks with no -/// minor unit (precious metals `XAU`/`XAG`, the IMF SDR `XDR`, `XXX`, test codes) -/// — none of which are spendable currencies that could be a domain price. -fn currency_decimals(code: &str) -> u32 { - iso_currency::Currency::from_code(&code.to_ascii_uppercase()) - .and_then(|c| c.exponent()) - .map_or(2, u32::from) -} - -/// Render a [`types::SimpleMoney`] as a decimal string. v3 money `value`s are in -/// ISO-4217 minor units for the currency (e.g. USD `1199` → `"11.99"`, JPY `1500` -/// → `"1500"`, BHD `1234` → `"1.234"`), NOT the micro-units v1 used. Truncates -/// toward zero (registry prices are whole minor units in practice); the sign is -/// explicit and `unsigned_abs` avoids `i64::MIN` overflow. `None` when the amount -/// is absent. Missing currency defaults to 2 decimals. -pub(super) fn format_money(money: &types::SimpleMoney) -> Option { - let value = money.value?; - let code = money - .currency_code - .as_ref() - .map(|c| c.as_str()) - .unwrap_or(""); - let decimals = currency_decimals(code); - let sign = if value < 0 { "-" } else { "" }; - let abs = value.unsigned_abs(); - if decimals == 0 { - return Some(format!("{sign}{abs}")); - } - let scale = 10u64.pow(decimals); - Some(format!( - "{sign}{}.{:0width$}", - abs / scale, - abs % scale, - width = decimals as usize - )) -} - -/// The entry for a specific year-term period (1, 2, …), or `None` when that term -/// isn't in the list. Used by `suggest` to flatten multiple terms into scalar -/// per-period fields (e.g. `price1Year`, `price2Year`). -pub(super) fn term_for_period( - prices: &[types::TermPrice], - period: u64, -) -> Option<&types::TermPrice> { - let period = std::num::NonZeroU64::new(period)?; - prices.iter().find(|p| p.period == Some(period)) -} - -/// Sorted, unique registration periods (years) priced in an availability response. -pub(super) fn periods_from_prices(prices: &[types::TermPrice]) -> Vec { - let mut periods: Vec = prices - .iter() - .filter_map(|t| t.period.map(|p| p.get())) - .collect(); - periods.sort_unstable(); - periods.dedup(); - periods -} - -/// Indicative total registration price keyed by period years. -pub(super) fn period_price_map( - prices: &[types::TermPrice], -) -> std::collections::BTreeMap { - let mut map = std::collections::BTreeMap::new(); - for term in prices { - let Some(period) = term.period.map(|p| p.get()) else { - continue; - }; - if let Some(price) = term.price.as_ref().and_then(format_money) { - map.insert(period, price); - } - } - map -} - -/// Whether an API error body indicates the requested registration period exceeds -/// the TLD limit. Availability pricing is indicative; quote is authoritative. -pub(super) fn is_period_limit_error(body: &str) -> bool { - let lower = body.to_ascii_lowercase(); - lower.contains("not currently supported") || lower.contains("maximum is") -} - -/// Parse the maximum registration period from a period-limit error body. -pub(super) fn parse_max_registration_period(body: &str) -> Option { - let lower = body.to_ascii_lowercase(); - let needle = "maximum is "; - let rest = lower.split(needle).nth(1)?; - rest.split_whitespace().next()?.parse().ok() -} - -/// Drop unsupported periods and clamp the selected period to the TLD maximum. -pub(super) fn clamp_registration_periods( - available_periods: &mut Vec, - selected_period: &mut u64, - max_years: u64, -) { - available_periods.retain(|p| *p <= max_years); - if available_periods.is_empty() { - available_periods.push(1); - } - if *selected_period > max_years { - *selected_period = available_periods.iter().copied().max().unwrap_or(1); - } -} - -/// A registration length with its unit spelled out ("1 year", "2 years") — a -/// bare number reads ambiguously in a table, so `quote`/`available` show this -/// alongside the numeric `period` field (which stays a plain number for -/// scripting against `--output json`). -pub(super) fn period_label(period: u64) -> String { - if period == 1 { - "1 year".to_owned() - } else { - format!("{period} years") - } -} - -/// Validate that `raw` (trimmed) is syntactically a valid domain name. Rejects -/// the shapes from DEVEX-885's bug report — embedded whitespace, null bytes, a -/// "protocol://" prefix, a "/path" suffix — via `url::Host::parse`'s WHATWG -/// forbidden-host-code-point + IDNA checks, then layers RFC 1035/1123 shape -/// rules a real domain always satisfies: not a bare IP literal, >=2 -/// dot-separated LDH labels, each 1-63 bytes, total <=253 bytes, and a TLD -/// that isn't all-numeric (ICANN disallows those). Returns the original -/// trimmed input — not `Host::parse`'s ASCII/punycode form — so an -/// already-working Unicode domain's wire format is unchanged. -pub(super) fn validate_domain_name(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(CliCoreError::message("domain name is required")); - } - // `url::Host::parse`'s error `Display` is a single generic string - // ("invalid international domain name") regardless of what's actually - // wrong (a space, a "://" prefix, a "/path" suffix, ...), so it isn't - // worth including — it would just read as a confusing non-sequitur. - let host = url::Host::parse(trimmed) - .map_err(|_| CliCoreError::message(format!("{trimmed:?} is not a valid domain name")))?; - let url::Host::Domain(ascii) = host else { - return Err(CliCoreError::message(format!( - "{trimmed:?} is an IP address, not a domain name" - ))); - }; - let labels: Vec<&str> = ascii.split('.').collect(); - let shape_ok = labels.len() >= 2 - && ascii.len() <= 253 - && labels.iter().all(|l| is_ldh_label(l)) - && !labels - .last() - .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())); - if !shape_ok { - return Err(CliCoreError::message(format!( - "{trimmed:?} doesn't look like a valid domain name (expected something like example.com)" - ))); - } - Ok(trimmed.to_owned()) -} - -/// Normalize and validate a TLD label for `--tld` / `--tlds` flags. -/// -/// Accepts `com` or `.com` (leading dot stripped), lowercases, and validates -/// each dot-separated label as LDH. -pub(super) fn validate_tld(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(CliCoreError::message("TLD is required")); - } - let stripped = trimmed.trim_start_matches('.'); - if stripped.is_empty() { - return Err(CliCoreError::message( - "TLD is required (expected something like com, not just a leading dot)", - )); - } - let lower = stripped.to_ascii_lowercase(); - let labels: Vec<&str> = lower.split('.').collect(); - if labels.iter().any(|label| !is_ldh_label(label)) { - return Err(CliCoreError::message(format!( - "{raw:?} doesn't look like a valid TLD (expected something like com or co.uk, without a leading dot)" - ))); - } - if labels - .last() - .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())) - { - return Err(CliCoreError::message(format!("{raw:?} is not a valid TLD"))); - } - Ok(lower) -} - -/// Prompt until the user enters a valid TLD or cancels. -pub(super) fn prompt_validated_tld(prompt: &str) -> Result { - use dialoguer::Input; - - let input: String = Input::new() - .with_prompt(prompt) - .validate_with(|input: &String| -> std::result::Result<(), String> { - validate_tld(input).map(|_| ()).map_err(|e| e.to_string()) - }) - .interact_text() - .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; - validate_tld(&input) -} - -/// Validate `--tld` values, re-prompting interactively when input is invalid. -#[allow(clippy::print_stderr)] -pub(super) fn resolve_tlds( - ctx: &CommandContext, - raw: Vec, - prompt: &str, -) -> Result> { - if raw.is_empty() { - if ctx.is_interactive() { - return Ok(vec![prompt_validated_tld(prompt)?]); - } - return Err(CliCoreError::message("at least one --tld is required")); - } - match raw - .iter() - .map(|t| validate_tld(t)) - .collect::>>() - { - Ok(tlds) => Ok(tlds), - Err(e) if ctx.is_interactive() => { - eprintln!(" {e}"); - Ok(vec![prompt_validated_tld(prompt)?]) - } - Err(e) => Err(e), - } -} - -/// User-facing hint when the agreements API rejects a TLD the user can retry. -pub(super) fn recoverable_tld_api_error(status: u16, body: &str) -> Option { - if status != 422 { - return None; - } - #[derive(serde::Deserialize)] - struct CodeMessage { - #[serde(default)] - code: String, - #[serde(default)] - message: String, - } - let parsed = serde_json::from_str::(body).ok()?; - if parsed.code != "UNSUPPORTED_TLD" { - return None; - } - if parsed.message.is_empty() { - Some("that TLD is not supported; try another (e.g. com)".to_owned()) - } else { - Some(format!( - "that TLD is not supported: {} — try another (e.g. com)", - parsed.message - )) - } -} - -/// User-facing hint when an operation lookup fails and the user can retry. -pub(super) fn recoverable_operation_api_error(status: u16, _body: &str) -> Option { - if status == 404 { - Some("no operation found with that ID — check the operation ID and try again".to_owned()) - } else { - None - } -} - -/// User-facing hint when a domain lookup fails and the user can retry. -pub(super) fn recoverable_domain_lookup_api_error(status: u16, _body: &str) -> Option { - match status { - 404 => Some("that domain was not found — check the name and try again".to_owned()), - 422 => Some("that domain could not be looked up — check the name and try again".to_owned()), - _ => None, - } -} - -/// Normalize and validate an async operation ID (UUID). -pub(super) fn validate_operation_id(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(CliCoreError::message("operation ID is required")); - } - uuid::Uuid::parse_str(trimmed).map_err(|_| { - CliCoreError::message(format!( - "{trimmed:?} doesn't look like a valid operation ID (expected a UUID)" - )) - })?; - Ok(trimmed.to_owned()) -} - -/// Prompt until the user enters a valid operation ID or cancels. -pub(super) fn prompt_validated_operation_id(prompt: &str) -> Result { - use dialoguer::Input; - - let input: String = Input::new() - .with_prompt(prompt) - .validate_with(|input: &String| -> std::result::Result<(), String> { - validate_operation_id(input) - .map(|_| ()) - .map_err(|e| e.to_string()) - }) - .interact_text() - .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; - validate_operation_id(&input) -} - -/// Validate an operation ID, re-prompting interactively when input is invalid. -#[allow(clippy::print_stderr)] -pub(super) fn resolve_operation_id( - ctx: &CommandContext, - raw: &str, - prompt: &str, -) -> Result { - match validate_operation_id(raw) { - Ok(id) => Ok(id), - Err(e) if ctx.is_interactive() => { - eprintln!(" {e}"); - prompt_validated_operation_id(prompt) - } - Err(e) => Err(e), - } -} - -/// Validate a quote token string (non-empty). -pub(super) fn validate_quote_token(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(CliCoreError::message("quote token is required")); - } - Ok(trimmed.to_owned()) -} - -/// Prompt until the user enters a non-empty quote token or cancels. -pub(super) fn prompt_validated_quote_token(prompt: &str) -> Result { - use dialoguer::Input; - - let input: String = Input::new() - .with_prompt(prompt) - .validate_with(|input: &String| -> std::result::Result<(), String> { - validate_quote_token(input) - .map(|_| ()) - .map_err(|e| e.to_string()) - }) - .interact_text() - .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; - validate_quote_token(&input) -} - -/// Resolve a quote token, re-prompting interactively when missing or invalid. -#[allow(clippy::print_stderr)] -pub(super) fn resolve_quote_token( - ctx: &CommandContext, - raw: Option, - prompt: &str, -) -> Result { - match raw { - Some(token) => match validate_quote_token(&token) { - Ok(token) => Ok(token), - Err(e) if ctx.is_interactive() => { - eprintln!(" {e}"); - prompt_validated_quote_token(prompt) - } - Err(e) => Err(e), - }, - None if ctx.is_interactive() => prompt_validated_quote_token(prompt), - None => Err(CliCoreError::message( - "--quote-token is required.\n\ - \n Get one with `gddy domain quote `, or use the interactive \ - wizard:\n\ - \n gddy domain register\n\ - \n Then purchase with:\n\ - \n gddy domain purchase --quote-token --agree --confirm", - )), - } -} - -/// Validate optional `--tlds` filters, re-prompting interactively when invalid. -#[allow(clippy::print_stderr)] -pub(super) fn resolve_optional_tlds( - ctx: &CommandContext, - raw: Vec, - prompt: &str, -) -> Result> { - if raw.is_empty() { - return Ok(vec![]); - } - match raw - .iter() - .map(|t| validate_tld(t)) - .collect::>>() - { - Ok(tlds) => Ok(tlds), - Err(e) if ctx.is_interactive() => { - eprintln!(" {e}"); - Ok(vec![prompt_validated_tld(prompt)?]) - } - Err(e) => Err(e), - } -} - -/// Prompt until the user enters a valid domain name or cancels. -pub(super) fn prompt_validated_domain_name(prompt: &str) -> Result { - use dialoguer::Input; - - let input: String = Input::new() - .with_prompt(prompt) - .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) -} - -/// Validate a domain name, re-prompting interactively when input is invalid. -/// -/// Used by leaf commands whose positional `DOMAIN` arg may be supplied via -/// cli-engine's missing-arg recovery (which does not validate format). -#[allow(clippy::print_stderr)] // interactive user-facing feedback, not diagnostic logging -pub(super) fn resolve_domain_name(ctx: &CommandContext, raw: &str, prompt: &str) -> Result { - match validate_domain_name(raw) { - Ok(domain) => Ok(domain), - Err(e) if ctx.is_interactive() => { - eprintln!(" {e}"); - prompt_validated_domain_name(prompt) - } - Err(e) => Err(e), - } -} - -/// Validate every value of a repeatable `--nameserver`-style flag as a -/// domain-shaped hostname, wrapping [`validate_domain_name`]'s generic error -/// with the flag's own name — used by both `quote` (the registration -/// profile's nameservers) and `nameservers set` (the target domain's -/// nameservers) so a bad host isn't reported as if it were some other -/// (already-valid) domain argument. -pub(super) fn validate_nameserver_hosts(raw: Vec) -> Result> { - raw.into_iter() - .map(|h| { - validate_domain_name(&h).map_err(|e| CliCoreError::message(format!("--nameserver {e}"))) - }) - .collect() -} - -/// Prompt until the user enters a valid nameserver host or cancels. -pub(super) fn prompt_validated_nameserver(prompt: &str) -> Result { - use dialoguer::Input; - - let input: String = Input::new() - .with_prompt(prompt) - .validate_with(|input: &String| -> std::result::Result<(), String> { - validate_domain_name(input) - .map(|_| ()) - .map_err(|e| format!("--nameserver {e}")) - }) - .interact_text() - .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; - validate_domain_name(&input) -} - -/// Resolve `--nameserver` hosts, re-prompting interactively when missing or invalid. -#[allow(clippy::print_stderr)] -pub(super) fn resolve_nameserver_hosts( - ctx: &CommandContext, - raw: Vec, - prompt: &str, -) -> Result> { - if raw.is_empty() { - if ctx.is_interactive() { - return Ok(vec![prompt_validated_nameserver(prompt)?]); - } - return Err(CliCoreError::message( - "at least one --nameserver is required", - )); - } - match validate_nameserver_hosts(raw) { - Ok(hosts) => Ok(hosts), - Err(e) if ctx.is_interactive() => { - eprintln!(" {e}"); - Ok(vec![prompt_validated_nameserver(prompt)?]) - } - Err(e) => Err(e), - } -} - -/// Re-run a domain-scoped API call, re-prompting for the domain on recoverable errors. -#[allow(clippy::print_stderr)] -pub(super) async fn fetch_with_domain_retry( - ctx: &CommandContext, - initial_domain: String, - prompt: &str, - action: &str, - debug: bool, - fetch: F, -) -> Result -where - F: Fn(domains_client::Client, String) -> Fut, - Fut: Future>>, -{ - let client = make_client(ctx).await?; - let mut domain = initial_domain; - loop { - match fetch(client.clone(), domain.clone()).await { - Ok(value) => return Ok(value), - Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { - let status = resp.status().as_u16(); - let status_display = resp.status().to_string(); - let request_id = resp - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - let body = resp.text().await.unwrap_or_default(); - if let Some(hint) = recoverable_domain_lookup_api_error(status, &body) { - eprintln!(" {hint}"); - domain = prompt_validated_domain_name(prompt)?; - continue; - } - return Err(CliCoreError::message(format_api_error( - action, - status, - &status_display, - &body, - request_id.as_deref(), - debug, - ))); - } - Err(e) => return Err(api_error(action, debug, e).await), - } - } -} - -/// Re-run an operation lookup, re-prompting for the operation ID on recoverable errors. -#[allow(clippy::print_stderr)] -pub(super) async fn fetch_with_operation_retry( - ctx: &CommandContext, - initial_id: String, - prompt: &str, - action: &str, - debug: bool, - fetch: F, -) -> Result -where - F: Fn(domains_client::Client, String) -> Fut, - Fut: Future>>, -{ - let client = make_client(ctx).await?; - let mut operation_id = initial_id; - loop { - match fetch(client.clone(), operation_id.clone()).await { - Ok(value) => return Ok(value), - Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { - let status = resp.status().as_u16(); - let status_display = resp.status().to_string(); - let request_id = resp - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - let body = resp.text().await.unwrap_or_default(); - if let Some(hint) = recoverable_operation_api_error(status, &body) { - eprintln!(" {hint}"); - operation_id = prompt_validated_operation_id(prompt)?; - continue; - } - return Err(CliCoreError::message(format_api_error( - action, - status, - &status_display, - &body, - request_id.as_deref(), - debug, - ))); - } - Err(e) => return Err(api_error(action, debug, e).await), - } - } -} - -/// A single RFC 1035/1123 "LDH label": 1-63 bytes, alphanumeric, interior -/// hyphens only (not leading/trailing). -fn is_ldh_label(label: &str) -> bool { - let bytes = label.as_bytes(); - !bytes.is_empty() - && bytes.len() <= 63 - && bytes[0] != b'-' - && bytes[bytes.len() - 1] != b'-' - && bytes - .iter() - .all(|b| b.is_ascii_alphanumeric() || *b == b'-') -} - -/// Whether an async domain-operation status is terminal (no further polling). -/// Only `COMPLETED`/`FAILED` are terminal; every other status (e.g. `SUBMITTED`, -/// `PENDING`, `CONFIRMED`, `EXECUTING`) is treated as still in progress. -pub(super) fn is_terminal_status(status: &str) -> bool { - matches!(status, "COMPLETED" | "FAILED") -} - -/// Renders a `FAILED` operation's `error` payload (name/message) for the -/// user-facing error, e.g. `" (DOMAIN_UNAVAILABLE: the domain is already -/// registered)"`. Empty when the operation carried no error detail (e.g. it -/// failed before an operation with a populated `error` was ever polled). -pub(super) fn format_operation_error(error: Option<&types::Error>) -> String { - let Some(error) = error else { - return String::new(); - }; - match (error.name.as_deref(), error.message.as_deref()) { - (Some(name), Some(message)) => format!(" ({name}: {message})"), - (Some(name), None) => format!(" ({name})"), - (None, Some(message)) => format!(" ({message})"), - (None, None) => String::new(), - } -} - -/// Build a Domains API client for the active environment, authenticating with -/// the resolved OAuth bearer token. -pub(crate) async fn make_client(ctx: &CommandContext) -> Result { - let cred = ctx.credential().await?; - make_client_with_cred(&ctx.middleware.env, &cred) -} - -/// Build the Domains API client from an already-resolved credential, so callers -/// that need the credential themselves (e.g. `purchase`, for the consent -/// principal) resolve it once and reuse the same token for the requests. -pub(crate) fn make_client_with_cred( - env: &str, - cred: &Credential, -) -> Result { - ensure_transport_observer_registered(); - let config = environments::resolve(env)?; - let authorization = format!("Bearer {}", cred.token); - let request_id = uuid::Uuid::new_v4().to_string(); - domains_client::client_with_auth( - &config.domains_api_url, - &authorization, - USER_AGENT, - &request_id, - ) - .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) -} - -/// Collapse a repeatable CLI flag's values into the single query-string value -/// some domains-client endpoints require (e.g. `tlds`, whose OpenAPI param is -/// `style: form, explode: false` — one comma-joined value). progenitor's -/// generated setters always seq-serialize a `Vec` argument as repeated -/// `key=value` pairs regardless of the spec's `explode` setting, so passing -/// multiple `--tlds` occurrences straight through sends `tlds=com&tlds=net` -/// and the API rejects it (`400 MISMATCH_FORMAT`, DEVEX-882). Joining into a -/// single element before calling the setter produces the one pair the API -/// expects. `[]` stays `[]` so callers can still gate on "no filter given". -pub(crate) fn comma_joined(values: Vec) -> Vec { - if values.len() <= 1 { - values - } else { - vec![values.join(",")] - } -} - -/// Turn a domains-client error into a `CliCoreError`, reading the response body -/// for unexpected (non-2xx) responses so the API's actual message isn't lost -/// (progenitor's `Display` prints only the status). Async because reading the -/// body is async. -pub(crate) async fn api_error( - action: &str, - debug: bool, - err: domains_client::Error<()>, -) -> CliCoreError { - match err { - domains_client::Error::UnexpectedResponse(resp) => { - let status = resp.status(); - let request_id = resp - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - let body = resp.text().await.unwrap_or_default(); - CliCoreError::message(format_api_error( - action, - status.as_u16(), - &status.to_string(), - &body, - request_id.as_deref(), - debug, - )) - } - other => CliCoreError::message(format!("{action} failed: {other}")), - } -} - -/// Build the user-facing message for an unexpected API response. Pure so the -/// HTTP 402 → payment-method guidance and the `--debug` request-id line are -/// unit-testable. -pub(crate) fn format_api_error( - action: &str, - status: u16, - status_display: &str, - body: &str, - request_id: Option<&str>, - debug: bool, -) -> String { - let body = body.trim(); - let friendly = friendly_field_errors(body); - let mut msg = match &friendly { - Some(detail) => format!("{action} failed (HTTP {status_display}):\n{detail}"), - None if body.is_empty() => format!("{action} failed (HTTP {status_display})"), - None => format!("{action} failed (HTTP {status_display}): {body}"), - }; - if status == 402 { - msg.push_str( - "\n\nThis usually means your account has no usable payment method. Add one with \ - `gddy payment-methods add` (a credit card or Good-as-Gold balance is required for domain \ - purchases), then try again.", - ); - } - if debug { - if friendly.is_some() && !body.is_empty() { - msg.push_str(&format!("\n\nResponse body: {body}")); - } - if let Some(id) = request_id.map(str::trim).filter(|s| !s.is_empty()) { - msg.push_str(&format!("\n\nRequest ID: {id}")); - } - } - msg -} - -/// A Domains API validation error body. Tolerates the v1 field-level shape -/// (`{"fields":[...]}` or `{"error":{"fields":[...]}}`) and the v3 top-level -/// shape (`{"name","message","correlationId","details":[{"issue","description"}]}`). -#[derive(serde::Deserialize)] -struct ApiErrorBody { - #[serde(default)] - fields: Vec, - #[serde(default)] - error: Option, - #[serde(default)] - details: Vec, -} - -#[derive(serde::Deserialize)] -struct ApiErrorEnvelope { - #[serde(default)] - fields: Vec, -} - -#[derive(serde::Deserialize)] -struct ApiFieldError { - #[serde(default)] - code: String, - #[serde(default)] - path: String, -} - -#[derive(serde::Deserialize)] -struct ApiDetailError { - #[serde(default)] - description: String, - #[serde(default)] - issue: String, -} - -impl ApiDetailError { - /// The human-readable `description` when present; the v3 spec documents it - /// as unstable ("MAY change... MUST NOT depend on this value") and often - /// absent, so fall back to the stable `issue` code rather than dropping the - /// detail entirely. - fn render(&self) -> Option<&str> { - if !self.description.is_empty() { - Some(self.description.as_str()) - } else if !self.issue.is_empty() { - Some(self.issue.as_str()) - } else { - None - } - } -} - -/// Render a structured validation error as a plain-English bullet list, or `None` -/// when `body` isn't a field-level validation error. -fn friendly_field_errors(body: &str) -> Option { - let parsed = serde_json::from_str::(body).ok()?; - let fields = if !parsed.fields.is_empty() { - parsed.fields - } else { - parsed.error.map(|e| e.fields).unwrap_or_default() - }; - if !fields.is_empty() { - let lines: Vec = fields - .iter() - .map(|f| format!(" • {}", describe_field_error(f))) - .collect(); - return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); - } - if !parsed.details.is_empty() { - let lines: Vec = parsed - .details - .iter() - .filter_map(ApiDetailError::render) - .map(|text| format!(" • {text}")) - .collect(); - if !lines.is_empty() { - return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); - } - } - None -} - -fn describe_field_error(f: &ApiFieldError) -> String { - let name = friendly_field_name(&f.path); - let problem = match f.code.as_str() { - "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT" => "is too short", - "LENGTH_OVER" | "MAX_LENGTH" | "TOO_LONG" => "is too long", - "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN" => "is not in the required format", - "REQUIRED" | "MISSING" | "MISSING_REQUIRED_FIELD" => "is required", - _ => "is invalid", - }; - match field_hint(&f.path, &f.code) { - Some(hint) => format!("{name} {problem} ({hint})"), - None => format!("{name} {problem}"), - } -} - -fn field_hint(path: &str, code: &str) -> Option<&'static str> { - let leaf = path.rsplit('.').next().unwrap_or(path); - match (leaf, code) { - ("phone", _) | ("nationalNumber", _) => Some("expected a format like +1.4805551212"), - ("postalCode", "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN") => { - Some("some countries require a specific format, e.g. UK SW1A 2AA") - } - ("line2", "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT") => Some("leave it blank to omit it"), - _ => None, - } -} - -/// Turn an API field path into a human phrase. Falls back to a space-joined, -/// camelCase-split rendering for unrecognized paths. -fn friendly_field_name(path: &str) -> String { - let trimmed = path - .strip_prefix("body.") - .or_else(|| path.strip_prefix("consent.")) - .unwrap_or(path); - let segments: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect(); - if let ["contacts", role, rest @ ..] = segments.as_slice() { - let mut parts = vec![humanize_segment(role)]; - parts.extend(rest.iter().map(|s| humanize_segment(s))); - return parts.join(" "); - } - if segments.is_empty() { - return path.to_string(); - } - segments - .iter() - .map(|s| humanize_segment(s)) - .collect::>() - .join(" ") -} - -fn humanize_segment(seg: &str) -> String { - match seg { - "line1" => "line 1".to_string(), - "line2" => "line 2".to_string(), - "postalCode" => "postal code".to_string(), - "countryCode" => "country".to_string(), - "firstName" => "first name".to_string(), - "lastName" => "last name".to_string(), - "nationalNumber" => "phone".to_string(), - "agreedAt" => "agreed-at timestamp".to_string(), - other => split_camel_case(other), - } -} - -fn split_camel_case(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 4); - for (i, ch) in s.char_indices() { - if ch.is_ascii_uppercase() { - if i != 0 { - out.push(' '); - } - out.push(ch.to_ascii_lowercase()); - } else { - out.push(ch); - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn comma_joined_collapses_multiple_values_into_one_element() { - // Regression for DEVEX-882: repeated `--tlds`/`--tld` values must become - // one comma-joined query element, not stay as separate elements (which - // progenitor would send as repeated `tlds=` pairs). - assert_eq!( - comma_joined(vec!["com".to_string(), "net".to_string(), "io".to_string()]), - vec!["com,net,io".to_string()] - ); - } - - #[test] - fn comma_joined_single_value_is_unchanged() { - assert_eq!( - comma_joined(vec!["com".to_string()]), - vec!["com".to_string()] - ); - } - - #[test] - fn comma_joined_empty_stays_empty() { - assert_eq!(comma_joined(Vec::::new()), Vec::::new()); - } - - #[test] - fn periods_from_prices_returns_sorted_unique_periods() { - use domains_client::types; - - let prices = vec![ - types::TermPrice { - period: std::num::NonZeroU64::new(3), - ..Default::default() - }, - types::TermPrice { - period: std::num::NonZeroU64::new(1), - ..Default::default() - }, - types::TermPrice { - period: std::num::NonZeroU64::new(2), - ..Default::default() - }, - types::TermPrice { - period: std::num::NonZeroU64::new(2), - ..Default::default() - }, - ]; - assert_eq!(periods_from_prices(&prices), vec![1, 2, 3]); - } - - #[test] - fn period_price_map_formats_indicative_totals() { - use domains_client::types; - - let prices = vec![types::TermPrice { - period: std::num::NonZeroU64::new(2), - price: Some(types::SimpleMoney { - value: Some(6098), - currency_code: Some(types::CurrencyCode("USD".to_string())), - }), - ..Default::default() - }]; - let map = period_price_map(&prices); - assert_eq!(map.get(&2).map(String::as_str), Some("60.98")); - } - - #[test] - fn parse_max_registration_period_reads_api_error_text() { - let body = r#"{"details":[{"description":"period 5 is not currently supported; maximum is 3 years"}]}"#; - assert!(is_period_limit_error(body)); - assert_eq!(parse_max_registration_period(body), Some(3)); - } - - #[test] - fn clamp_registration_periods_drops_and_clamps_selection() { - let mut periods = vec![1_u64, 2, 3, 5]; - let mut selected = 5_u64; - clamp_registration_periods(&mut periods, &mut selected, 3); - assert_eq!(periods, vec![1, 2, 3]); - assert_eq!(selected, 3); - } - - fn money(value: Option, currency: &str) -> types::SimpleMoney { - types::SimpleMoney { - value, - currency_code: (!currency.is_empty()) - .then(|| types::CurrencyCode(currency.to_string())), - } - } - - #[test] - fn format_money_uses_iso4217_minor_units_per_currency() { - // v3 `value` is in the currency's ISO-4217 minor units — USD `1199` is - // $11.99, NOT micro-units (the regression that rendered real prices as 0.00). - assert_eq!( - format_money(&money(Some(1199), "USD")).as_deref(), - Some("11.99") - ); - assert_eq!( - format_money(&money(Some(100), "USD")).as_deref(), - Some("1.00") - ); - assert_eq!( - format_money(&money(Some(2050), "USD")).as_deref(), - Some("20.50") - ); - assert_eq!( - format_money(&money(Some(-500), "USD")).as_deref(), - Some("-5.00") - ); - // Zero-decimal (JPY) and three-decimal (BHD) currencies. - assert_eq!( - format_money(&money(Some(1500), "JPY")).as_deref(), - Some("1500") - ); - assert_eq!( - format_money(&money(Some(1234), "BHD")).as_deref(), - Some("1.234") - ); - // Missing amount → None; missing currency defaults to 2 decimals. - assert_eq!(format_money(&money(None, "USD")), None); - assert_eq!( - format_money(&money(Some(1199), "")).as_deref(), - Some("11.99") - ); - } - - #[test] - fn currency_decimals_covers_iso_exceptions() { - assert_eq!(currency_decimals("USD"), 2); - assert_eq!(currency_decimals("EUR"), 2); - assert_eq!(currency_decimals("JPY"), 0); - assert_eq!(currency_decimals("UYI"), 0); - assert_eq!(currency_decimals("KWD"), 3); - assert_eq!(currency_decimals("CLF"), 4); - assert_eq!(currency_decimals("UYW"), 4); - assert_eq!(currency_decimals("XAU"), 2); // no ISO minor unit → default 2 (never a price) - assert_eq!(currency_decimals("ZZZ"), 2); // unknown → default 2 - } - - #[test] - fn payment_required_error_points_to_payment_methods_add() { - let msg = format_api_error( - "domain purchase", - 402, - "402 Payment Required", - r#"{"code":"INVALID_PAYMENT_INFO","message":"Unable to authorize credit"}"#, - None, - false, - ); - assert!(msg.contains("402 Payment Required"), "{msg}"); - assert!(msg.contains("gddy payment-methods add"), "{msg}"); - } - - #[test] - fn v3_error_envelope_fields_render_as_plain_english() { - // v3 wraps validation detail under an `error` object; the friendly - // renderer must reach into it. - let body = r#"{"error":{"code":"INVALID_BODY","fields":[{"code":"LENGTH_UNDER","path":"contacts.registrant.address.line2"}]}}"#; - let msg = format_api_error( - "domain purchase", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!(msg.contains("registrant address line 2"), "{msg}"); - assert!(msg.contains("is too short"), "{msg}"); - assert!(msg.contains("leave it blank to omit"), "{msg}"); - } - - #[test] - fn validate_domain_name_rejects_devex_885_repro_cases() { - // Every case from the DEVEX-885 bug report except the trailing-space - // one (which is accepted-after-trim; see the test below). - for bad in [ - "not a domain", - "https://test.com", - "test.com/page", - "test\u{0}evil.com", - ] { - assert!( - validate_domain_name(bad).is_err(), - "{bad:?} should be rejected" - ); - } - } - - #[test] - fn validate_domain_name_trims_whitespace_instead_of_rejecting() { - assert_eq!( - validate_domain_name("test.com ").expect("trimmed to valid"), - "test.com" - ); - assert_eq!( - validate_domain_name(" test.com").expect("trimmed to valid"), - "test.com" - ); - } - - #[test] - fn validate_domain_name_rejects_empty_or_whitespace_only() { - assert!(validate_domain_name("").is_err()); - assert!(validate_domain_name(" ").is_err()); - } - - #[test] - fn validate_domain_name_rejects_ip_literals() { - assert!(validate_domain_name("1.2.3.4").is_err()); - assert!(validate_domain_name("::1").is_err()); - } - - #[test] - fn validate_domain_name_rejects_missing_or_numeric_tld() { - assert!(validate_domain_name("example").is_err()); - assert!(validate_domain_name("example.123").is_err()); - } - - #[test] - fn validate_tld_strips_leading_dot_and_lowercases() { - assert_eq!(validate_tld(".org").expect("valid"), "org"); - assert_eq!(validate_tld("COM").expect("valid"), "com"); - assert_eq!(validate_tld("co.uk").expect("valid"), "co.uk"); - } - - #[test] - fn validate_tld_rejects_empty_or_dot_only() { - assert!(validate_tld("").is_err()); - assert!(validate_tld(".").is_err()); - assert!(validate_tld(" ").is_err()); - } - - #[test] - fn recoverable_tld_api_error_matches_unsupported_tld() { - let hint = recoverable_tld_api_error( - 422, - r#"{"code":"UNSUPPORTED_TLD","message":"The specified TLD is currently unsupported"}"#, - ) - .expect("recoverable"); - assert!(hint.contains("not supported")); - } - - #[test] - fn recoverable_tld_api_error_ignores_other_codes() { - assert!(recoverable_tld_api_error(422, r#"{"code":"OTHER","message":"nope"}"#).is_none()); - assert!(recoverable_tld_api_error(404, r#"{"code":"UNSUPPORTED_TLD"}"#).is_none()); - } - - #[test] - fn validate_operation_id_accepts_uuid() { - assert_eq!( - validate_operation_id("550e8400-e29b-41d4-a716-446655440000").expect("valid"), - "550e8400-e29b-41d4-a716-446655440000" - ); - } - - #[test] - fn validate_operation_id_rejects_garbage() { - assert!(validate_operation_id("not-a-uuid").is_err()); - assert!(validate_operation_id("").is_err()); - } - - #[test] - fn validate_domain_name_rejects_leading_or_trailing_hyphen_labels() { - assert!(validate_domain_name("-example.com").is_err()); - assert!(validate_domain_name("example-.com").is_err()); - } - - #[test] - fn validate_domain_name_accepts_well_formed_domains_unchanged() { - for good in ["example.com", "xn--fsq.com", "ns1.example.co.uk"] { - assert_eq!(validate_domain_name(good).expect("valid domain"), good); - } - } - - #[test] - fn validate_domain_name_accepts_unicode_and_returns_original_not_punycode() { - // A real Unicode domain must pass (IDNA-processed for the shape check) - // but the returned string is the user's original input, not - // `Host::parse`'s ASCII/punycode form — no wire-format change for - // domains that already work today. - let input = "café.com"; - assert_eq!( - validate_domain_name(input).expect("valid unicode domain"), - input - ); - } - - #[test] - fn validate_nameserver_hosts_rejects_bad_shape_with_flag_context() { - // Regression: `quote`'s and `nameservers set`'s `--nameserver` values - // must go through the same shape check as a domain arg, but the error - // must say `--nameserver`, not claim the bad value is "the domain". - let err = validate_nameserver_hosts(vec!["bad ns".to_string()]) - .expect_err("malformed host should be rejected"); - let msg = err.to_string(); - assert!(msg.starts_with("--nameserver "), "{msg}"); - assert!(msg.contains("bad ns"), "{msg}"); - } - - #[test] - fn validate_nameserver_hosts_passes_through_valid_hosts_unchanged() { - let hosts = vec!["ns1.example.com".to_string(), "ns2.example.com".to_string()]; - assert_eq!( - validate_nameserver_hosts(hosts.clone()).expect("valid hosts"), - hosts - ); - } - - #[test] - fn validate_nameserver_hosts_empty_list_stays_empty() { - assert_eq!( - validate_nameserver_hosts(Vec::new()).expect("empty is valid"), - Vec::::new() - ); - } - - #[test] - fn v3_top_level_details_render_as_plain_english() { - // The real body a DNS write returns on a name-exclusivity conflict: no - // `fields`/`error` envelope at all, just a top-level `details[]`. - let body = r#"{"correlationId":"abc-123","details":[{"description":"Duplicate data provided for record name, www.","issue":"DUPLICATE_RECORD"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; - let msg = format_api_error( - "dns set", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!( - msg.contains("Duplicate data provided for record name, www."), - "{msg}" - ); - } - - #[test] - fn v3_details_with_no_description_fall_back_to_the_issue_code() { - // `description` is documented as unstable and often omitted; `issue` is - // the stable code. A detail with only `issue` must still render - // something useful, not fall through to the generic opaque message. - let body = r#"{"correlationId":"abc-123","details":[{"issue":"INVALID_NAMESERVER"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; - let msg = format_api_error( - "dns set", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!(msg.contains("INVALID_NAMESERVER"), "{msg}"); - - // Both empty → no friendly rendering, falls through to the generic message. - let body = r#"{"correlationId":"abc-123","details":[{}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; - let msg = format_api_error( - "dns set", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!(!msg.contains("some fields are invalid"), "{msg}"); - } - - #[test] - fn period_label_pluralizes_correctly() { - assert_eq!(period_label(1), "1 year"); - assert_eq!(period_label(2), "2 years"); - assert_eq!(period_label(10), "10 years"); - } - - #[test] - fn terminal_status_detection() { - assert!(is_terminal_status("COMPLETED")); - assert!(is_terminal_status("FAILED")); - assert!(!is_terminal_status("CONFIRMED")); - assert!(!is_terminal_status("EXECUTING")); - assert!(!is_terminal_status("SUBMITTED")); - } - - #[test] - fn format_operation_error_includes_name_and_message() { - assert_eq!(format_operation_error(None), ""); - - let name_only = types::Error { - name: Some("DOMAIN_UNAVAILABLE".to_string()), - ..Default::default() - }; - assert_eq!( - format_operation_error(Some(&name_only)), - " (DOMAIN_UNAVAILABLE)" - ); - - let message_only = types::Error { - message: Some("the domain is already registered".to_string()), - ..Default::default() - }; - assert_eq!( - format_operation_error(Some(&message_only)), - " (the domain is already registered)" - ); - - let both = types::Error { - name: Some("DOMAIN_UNAVAILABLE".to_string()), - message: Some("the domain is already registered".to_string()), - ..Default::default() - }; - assert_eq!( - format_operation_error(Some(&both)), - " (DOMAIN_UNAVAILABLE: the domain is already registered)" - ); - - let neither = types::Error::default(); - assert_eq!(format_operation_error(Some(&neither)), ""); - } -} diff --git a/rust/src/domain/common/client.rs b/rust/src/domain/common/client.rs new file mode 100644 index 00000000..8ef3255d --- /dev/null +++ b/rust/src/domain/common/client.rs @@ -0,0 +1,58 @@ +use cli_engine::{CliCoreError, CommandContext, Credential, Result}; + +use crate::environments; + +const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); + +/// Bridges domains-client's request/response observations into cli-engine's +/// `--debug transport` logger. domains-client defines the `TransportObserver` +/// extension point itself and has no compile-time dependency on cli-engine — +/// this crate *pushes* the logging behavior in, rather than domains-client +/// *pulling* it from the engine. +struct CliEngineTransportObserver; + +impl domains_client::TransportObserver for CliEngineTransportObserver { + fn on_request(&self, request: &reqwest::Request) { + cli_engine::transport::debug_log_reqwest_request(request); + } + + fn on_response(&self, status: reqwest::StatusCode, headers: &reqwest::header::HeaderMap) { + cli_engine::transport::debug_log_reqwest_response(status, headers, &[]); + } +} + +static TRANSPORT_OBSERVER_INIT: std::sync::Once = std::sync::Once::new(); + +fn ensure_transport_observer_registered() { + TRANSPORT_OBSERVER_INIT.call_once(|| { + domains_client::set_transport_observer(Some(std::sync::Arc::new( + CliEngineTransportObserver, + ))); + }); +} +/// Build a Domains API client for the active environment, authenticating with +/// the resolved OAuth bearer token. +pub(crate) async fn make_client(ctx: &CommandContext) -> Result { + let cred = ctx.credential().await?; + make_client_with_cred(&ctx.middleware.env, &cred) +} + +/// Build the Domains API client from an already-resolved credential, so callers +/// that need the credential themselves (e.g. `purchase`, for the consent +/// principal) resolve it once and reuse the same token for the requests. +pub(crate) fn make_client_with_cred( + env: &str, + cred: &Credential, +) -> Result { + ensure_transport_observer_registered(); + let config = environments::resolve(env)?; + let authorization = format!("Bearer {}", cred.token); + let request_id = uuid::Uuid::new_v4().to_string(); + domains_client::client_with_auth( + &config.domains_api_url, + &authorization, + USER_AGENT, + &request_id, + ) + .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) +} diff --git a/rust/src/domain/common/errors.rs b/rust/src/domain/common/errors.rs new file mode 100644 index 00000000..02f0659b --- /dev/null +++ b/rust/src/domain/common/errors.rs @@ -0,0 +1,313 @@ +use cli_engine::CliCoreError; + +/// Turn a domains-client error into a `CliCoreError`, reading the response body +/// for unexpected (non-2xx) responses so the API's actual message isn't lost +/// (progenitor's `Display` prints only the status). Async because reading the +/// body is async. +pub(crate) async fn api_error( + action: &str, + debug: bool, + err: domains_client::Error<()>, +) -> CliCoreError { + match err { + domains_client::Error::UnexpectedResponse(resp) => { + let status = resp.status(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + CliCoreError::message(format_api_error( + action, + status.as_u16(), + &status.to_string(), + &body, + request_id.as_deref(), + debug, + )) + } + other => CliCoreError::message(format!("{action} failed: {other}")), + } +} + +/// Build the user-facing message for an unexpected API response. Pure so the +/// HTTP 402 → payment-method guidance and the `--debug` request-id line are +/// unit-testable. +pub(crate) fn format_api_error( + action: &str, + status: u16, + status_display: &str, + body: &str, + request_id: Option<&str>, + debug: bool, +) -> String { + let body = body.trim(); + let friendly = friendly_field_errors(body); + let mut msg = match &friendly { + Some(detail) => format!("{action} failed (HTTP {status_display}):\n{detail}"), + None if body.is_empty() => format!("{action} failed (HTTP {status_display})"), + None => format!("{action} failed (HTTP {status_display}): {body}"), + }; + if status == 402 { + msg.push_str( + "\n\nThis usually means your account has no usable payment method. Add one with \ + `gddy payment-methods add` (a credit card or Good-as-Gold balance is required for domain \ + purchases), then try again.", + ); + } + if debug { + if friendly.is_some() && !body.is_empty() { + msg.push_str(&format!("\n\nResponse body: {body}")); + } + if let Some(id) = request_id.map(str::trim).filter(|s| !s.is_empty()) { + msg.push_str(&format!("\n\nRequest ID: {id}")); + } + } + msg +} + +/// A Domains API validation error body. Tolerates the v1 field-level shape +/// (`{"fields":[...]}` or `{"error":{"fields":[...]}}`) and the v3 top-level +/// shape (`{"name","message","correlationId","details":[{"issue","description"}]}`). +#[derive(serde::Deserialize)] +struct ApiErrorBody { + #[serde(default)] + fields: Vec, + #[serde(default)] + error: Option, + #[serde(default)] + details: Vec, +} + +#[derive(serde::Deserialize)] +struct ApiErrorEnvelope { + #[serde(default)] + fields: Vec, +} + +#[derive(serde::Deserialize)] +struct ApiFieldError { + #[serde(default)] + code: String, + #[serde(default)] + path: String, +} + +#[derive(serde::Deserialize)] +struct ApiDetailError { + #[serde(default)] + description: String, + #[serde(default)] + issue: String, +} + +impl ApiDetailError { + /// The human-readable `description` when present; the v3 spec documents it + /// as unstable ("MAY change... MUST NOT depend on this value") and often + /// absent, so fall back to the stable `issue` code rather than dropping the + /// detail entirely. + fn render(&self) -> Option<&str> { + if !self.description.is_empty() { + Some(self.description.as_str()) + } else if !self.issue.is_empty() { + Some(self.issue.as_str()) + } else { + None + } + } +} + +/// Render a structured validation error as a plain-English bullet list, or `None` +/// when `body` isn't a field-level validation error. +fn friendly_field_errors(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok()?; + let fields = if !parsed.fields.is_empty() { + parsed.fields + } else { + parsed.error.map(|e| e.fields).unwrap_or_default() + }; + if !fields.is_empty() { + let lines: Vec = fields + .iter() + .map(|f| format!(" • {}", describe_field_error(f))) + .collect(); + return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); + } + if !parsed.details.is_empty() { + let lines: Vec = parsed + .details + .iter() + .filter_map(ApiDetailError::render) + .map(|text| format!(" • {text}")) + .collect(); + if !lines.is_empty() { + return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); + } + } + None +} + +fn describe_field_error(f: &ApiFieldError) -> String { + let name = friendly_field_name(&f.path); + let problem = match f.code.as_str() { + "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT" => "is too short", + "LENGTH_OVER" | "MAX_LENGTH" | "TOO_LONG" => "is too long", + "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN" => "is not in the required format", + "REQUIRED" | "MISSING" | "MISSING_REQUIRED_FIELD" => "is required", + _ => "is invalid", + }; + match field_hint(&f.path, &f.code) { + Some(hint) => format!("{name} {problem} ({hint})"), + None => format!("{name} {problem}"), + } +} + +fn field_hint(path: &str, code: &str) -> Option<&'static str> { + let leaf = path.rsplit('.').next().unwrap_or(path); + match (leaf, code) { + ("phone", _) | ("nationalNumber", _) => Some("expected a format like +1.4805551212"), + ("postalCode", "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN") => { + Some("some countries require a specific format, e.g. UK SW1A 2AA") + } + ("line2", "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT") => Some("leave it blank to omit it"), + _ => None, + } +} + +/// Turn an API field path into a human phrase. Falls back to a space-joined, +/// camelCase-split rendering for unrecognized paths. +fn friendly_field_name(path: &str) -> String { + let trimmed = path + .strip_prefix("body.") + .or_else(|| path.strip_prefix("consent.")) + .unwrap_or(path); + let segments: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect(); + if let ["contacts", role, rest @ ..] = segments.as_slice() { + let mut parts = vec![humanize_segment(role)]; + parts.extend(rest.iter().map(|s| humanize_segment(s))); + return parts.join(" "); + } + if segments.is_empty() { + return path.to_string(); + } + segments + .iter() + .map(|s| humanize_segment(s)) + .collect::>() + .join(" ") +} + +fn humanize_segment(seg: &str) -> String { + match seg { + "line1" => "line 1".to_string(), + "line2" => "line 2".to_string(), + "postalCode" => "postal code".to_string(), + "countryCode" => "country".to_string(), + "firstName" => "first name".to_string(), + "lastName" => "last name".to_string(), + "nationalNumber" => "phone".to_string(), + "agreedAt" => "agreed-at timestamp".to_string(), + other => split_camel_case(other), + } +} + +fn split_camel_case(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + for (i, ch) in s.char_indices() { + if ch.is_ascii_uppercase() { + if i != 0 { + out.push(' '); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payment_required_error_points_to_payment_methods_add() { + let msg = format_api_error( + "domain purchase", + 402, + "402 Payment Required", + r#"{"code":"INVALID_PAYMENT_INFO","message":"Unable to authorize credit"}"#, + None, + false, + ); + assert!(msg.contains("402 Payment Required"), "{msg}"); + assert!(msg.contains("gddy payment-methods add"), "{msg}"); + } + + #[test] + fn v3_error_envelope_fields_render_as_plain_english() { + // v3 wraps validation detail under an `error` object; the friendly + // renderer must reach into it. + let body = r#"{"error":{"code":"INVALID_BODY","fields":[{"code":"LENGTH_UNDER","path":"contacts.registrant.address.line2"}]}}"#; + let msg = format_api_error( + "domain purchase", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!(msg.contains("registrant address line 2"), "{msg}"); + assert!(msg.contains("is too short"), "{msg}"); + assert!(msg.contains("leave it blank to omit"), "{msg}"); + } + #[test] + fn v3_top_level_details_render_as_plain_english() { + // The real body a DNS write returns on a name-exclusivity conflict: no + // `fields`/`error` envelope at all, just a top-level `details[]`. + let body = r#"{"correlationId":"abc-123","details":[{"description":"Duplicate data provided for record name, www.","issue":"DUPLICATE_RECORD"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; + let msg = format_api_error( + "dns set", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!( + msg.contains("Duplicate data provided for record name, www."), + "{msg}" + ); + } + + #[test] + fn v3_details_with_no_description_fall_back_to_the_issue_code() { + // `description` is documented as unstable and often omitted; `issue` is + // the stable code. A detail with only `issue` must still render + // something useful, not fall through to the generic opaque message. + let body = r#"{"correlationId":"abc-123","details":[{"issue":"INVALID_NAMESERVER"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; + let msg = format_api_error( + "dns set", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!(msg.contains("INVALID_NAMESERVER"), "{msg}"); + + // Both empty → no friendly rendering, falls through to the generic message. + let body = r#"{"correlationId":"abc-123","details":[{}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; + let msg = format_api_error( + "dns set", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!(!msg.contains("some fields are invalid"), "{msg}"); + } + +} diff --git a/rust/src/domain/common/interactive.rs b/rust/src/domain/common/interactive.rs new file mode 100644 index 00000000..6e4447d4 --- /dev/null +++ b/rust/src/domain/common/interactive.rs @@ -0,0 +1,384 @@ +use std::future::Future; + +use cli_engine::{CliCoreError, CommandContext, Result}; + +use super::client::make_client; +use super::errors::{api_error, format_api_error}; +use super::validation::{ + validate_domain_name, validate_nameserver_hosts, validate_operation_id, validate_quote_token, + validate_tld, +}; + +/// Prompt until the user enters a valid TLD or cancels. +pub(crate) fn prompt_validated_tld(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_tld(input).map(|_| ()).map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_tld(&input) +} + +/// Validate `--tld` values, re-prompting interactively when input is invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_tlds( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + if ctx.is_interactive() { + return Ok(vec![prompt_validated_tld(prompt)?]); + } + return Err(CliCoreError::message("at least one --tld is required")); + } + match raw + .iter() + .map(|t| validate_tld(t)) + .collect::>>() + { + Ok(tlds) => Ok(tlds), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_tld(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// User-facing hint when the agreements API rejects a TLD the user can retry. +pub(crate) fn recoverable_tld_api_error(status: u16, body: &str) -> Option { + if status != 422 { + return None; + } + #[derive(serde::Deserialize)] + struct CodeMessage { + #[serde(default)] + code: String, + #[serde(default)] + message: String, + } + let parsed = serde_json::from_str::(body).ok()?; + if parsed.code != "UNSUPPORTED_TLD" { + return None; + } + if parsed.message.is_empty() { + Some("that TLD is not supported; try another (e.g. com)".to_owned()) + } else { + Some(format!( + "that TLD is not supported: {} — try another (e.g. com)", + parsed.message + )) + } +} + +/// User-facing hint when an operation lookup fails and the user can retry. +pub(crate) fn recoverable_operation_api_error(status: u16, _body: &str) -> Option { + if status == 404 { + Some("no operation found with that ID — check the operation ID and try again".to_owned()) + } else { + None + } +} + +/// User-facing hint when a domain lookup fails and the user can retry. +pub(crate) fn recoverable_domain_lookup_api_error(status: u16, _body: &str) -> Option { + match status { + 404 => Some("that domain was not found — check the name and try again".to_owned()), + 422 => Some("that domain could not be looked up — check the name and try again".to_owned()), + _ => None, + } +} + +/// Prompt until the user enters a valid operation ID or cancels. +pub(crate) fn prompt_validated_operation_id(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_operation_id(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_operation_id(&input) +} + +/// Validate an operation ID, re-prompting interactively when input is invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_operation_id( + ctx: &CommandContext, + raw: &str, + prompt: &str, +) -> Result { + match validate_operation_id(raw) { + Ok(id) => Ok(id), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_operation_id(prompt) + } + Err(e) => Err(e), + } +} + +/// Prompt until the user enters a non-empty quote token or cancels. +pub(crate) fn prompt_validated_quote_token(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_quote_token(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_quote_token(&input) +} + +/// Resolve a quote token, re-prompting interactively when missing or invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_quote_token( + ctx: &CommandContext, + raw: Option, + prompt: &str, +) -> Result { + match raw { + Some(token) => match validate_quote_token(&token) { + Ok(token) => Ok(token), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_quote_token(prompt) + } + Err(e) => Err(e), + }, + None if ctx.is_interactive() => prompt_validated_quote_token(prompt), + None => Err(CliCoreError::message( + "--quote-token is required.\n\ + \n Get one with `gddy domain quote `, or use the interactive \ + wizard:\n\ + \n gddy domain register\n\ + \n Then purchase with:\n\ + \n gddy domain purchase --quote-token --agree --confirm", + )), + } +} + +/// Validate optional `--tlds` filters, re-prompting interactively when invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_optional_tlds( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + return Ok(vec![]); + } + match raw + .iter() + .map(|t| validate_tld(t)) + .collect::>>() + { + Ok(tlds) => Ok(tlds), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_tld(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// Prompt until the user enters a valid domain name or cancels. +pub(crate) fn prompt_validated_domain_name(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .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) +} + +/// Validate a domain name, re-prompting interactively when input is invalid. +/// +/// Used by leaf commands whose positional `DOMAIN` arg may be supplied via +/// cli-engine's missing-arg recovery (which does not validate format). +#[allow(clippy::print_stderr)] // interactive user-facing feedback, not diagnostic logging +pub(crate) fn resolve_domain_name(ctx: &CommandContext, raw: &str, prompt: &str) -> Result { + match validate_domain_name(raw) { + Ok(domain) => Ok(domain), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_domain_name(prompt) + } + Err(e) => Err(e), + } +} + +/// Prompt until the user enters a valid nameserver host or cancels. +pub(crate) fn prompt_validated_nameserver(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_domain_name(input) + .map(|_| ()) + .map_err(|e| format!("--nameserver {e}")) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_domain_name(&input) +} + +/// Resolve `--nameserver` hosts, re-prompting interactively when missing or invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_nameserver_hosts( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + if ctx.is_interactive() { + return Ok(vec![prompt_validated_nameserver(prompt)?]); + } + return Err(CliCoreError::message( + "at least one --nameserver is required", + )); + } + match validate_nameserver_hosts(raw) { + Ok(hosts) => Ok(hosts), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_nameserver(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// Re-run a domain-scoped API call, re-prompting for the domain on recoverable errors. +#[allow(clippy::print_stderr)] +pub(crate) async fn fetch_with_domain_retry( + ctx: &CommandContext, + initial_domain: String, + prompt: &str, + action: &str, + debug: bool, + fetch: F, +) -> Result +where + F: Fn(domains_client::Client, String) -> Fut, + Fut: Future>>, +{ + let client = make_client(ctx).await?; + let mut domain = initial_domain; + loop { + match fetch(client.clone(), domain.clone()).await { + Ok(value) => return Ok(value), + Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_domain_lookup_api_error(status, &body) { + eprintln!(" {hint}"); + domain = prompt_validated_domain_name(prompt)?; + continue; + } + return Err(CliCoreError::message(format_api_error( + action, + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => return Err(api_error(action, debug, e).await), + } + } +} + +/// Re-run an operation lookup, re-prompting for the operation ID on recoverable errors. +#[allow(clippy::print_stderr)] +pub(crate) async fn fetch_with_operation_retry( + ctx: &CommandContext, + initial_id: String, + prompt: &str, + action: &str, + debug: bool, + fetch: F, +) -> Result +where + F: Fn(domains_client::Client, String) -> Fut, + Fut: Future>>, +{ + let client = make_client(ctx).await?; + let mut operation_id = initial_id; + loop { + match fetch(client.clone(), operation_id.clone()).await { + Ok(value) => return Ok(value), + Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_operation_api_error(status, &body) { + eprintln!(" {hint}"); + operation_id = prompt_validated_operation_id(prompt)?; + continue; + } + return Err(CliCoreError::message(format_api_error( + action, + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => return Err(api_error(action, debug, e).await), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recoverable_tld_api_error_matches_unsupported_tld() { + let hint = recoverable_tld_api_error( + 422, + r#"{"code":"UNSUPPORTED_TLD","message":"The specified TLD is currently unsupported"}"#, + ) + .expect("recoverable"); + assert!(hint.contains("not supported")); + } + + #[test] + fn recoverable_tld_api_error_ignores_other_codes() { + assert!(recoverable_tld_api_error(422, r#"{"code":"OTHER","message":"nope"}"#).is_none()); + assert!(recoverable_tld_api_error(404, r#"{"code":"UNSUPPORTED_TLD"}"#).is_none()); + } +} diff --git a/rust/src/domain/common/mod.rs b/rust/src/domain/common/mod.rs new file mode 100644 index 00000000..51e96699 --- /dev/null +++ b/rust/src/domain/common/mod.rs @@ -0,0 +1,73 @@ +//! Shared helpers for the `domain` command group: the authenticated Domains API +//! client, money formatting, argument helpers, and API-error rendering. Each +//! `gddy domain` subcommand lives in its own sibling module and draws from here. + + +mod client; +mod errors; +mod interactive; +mod operation; +mod pricing; +mod validation; + +pub(crate) use client::{make_client, make_client_with_cred}; +pub(crate) use errors::{api_error, format_api_error}; +pub(super) use interactive::{ + fetch_with_domain_retry, fetch_with_operation_retry, prompt_validated_domain_name, + prompt_validated_quote_token, prompt_validated_tld, recoverable_tld_api_error, + resolve_domain_name, resolve_nameserver_hosts, resolve_operation_id, resolve_optional_tlds, + resolve_quote_token, resolve_tlds, +}; +pub(super) use operation::{format_operation_error, is_terminal_status}; +pub(super) use pricing::{ + clamp_registration_periods, format_money, is_period_limit_error, parse_max_registration_period, + period_label, period_price_map, periods_from_prices, term_for_period, +}; +pub(super) use validation::{validate_domain_name, validate_nameserver_hosts}; + +/// Collapse a repeatable CLI flag's values into the single query-string value +/// some domains-client endpoints require (e.g. `tlds`, whose OpenAPI param is +/// `style: form, explode: false` — one comma-joined value). progenitor's +/// generated setters always seq-serialize a `Vec` argument as repeated +/// `key=value` pairs regardless of the spec's `explode` setting, so passing +/// multiple `--tlds` occurrences straight through sends `tlds=com&tlds=net` +/// and the API rejects it (`400 MISMATCH_FORMAT`, DEVEX-882). Joining into a +/// single element before calling the setter produces the one pair the API +/// expects. `[]` stays `[]` so callers can still gate on "no filter given". +pub(crate) fn comma_joined(values: Vec) -> Vec { + if values.len() <= 1 { + values + } else { + vec![values.join(",")] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comma_joined_collapses_multiple_values_into_one_element() { + // Regression for DEVEX-882: repeated `--tlds`/`--tld` values must become + // one comma-joined query element, not stay as separate elements (which + // progenitor would send as repeated `tlds=` pairs). + assert_eq!( + comma_joined(vec!["com".to_string(), "net".to_string(), "io".to_string()]), + vec!["com,net,io".to_string()] + ); + } + + #[test] + fn comma_joined_single_value_is_unchanged() { + assert_eq!( + comma_joined(vec!["com".to_string()]), + vec!["com".to_string()] + ); + } + + #[test] + fn comma_joined_empty_stays_empty() { + assert_eq!(comma_joined(Vec::::new()), Vec::::new()); + } + +} diff --git a/rust/src/domain/common/operation.rs b/rust/src/domain/common/operation.rs new file mode 100644 index 00000000..f1eca8ae --- /dev/null +++ b/rust/src/domain/common/operation.rs @@ -0,0 +1,75 @@ +use domains_client::types; + +/// Whether an async domain-operation status is terminal (no further polling). +/// Only `COMPLETED`/`FAILED` are terminal; every other status (e.g. `SUBMITTED`, +/// `PENDING`, `CONFIRMED`, `EXECUTING`) is treated as still in progress. +pub(crate) fn is_terminal_status(status: &str) -> bool { + matches!(status, "COMPLETED" | "FAILED") +} + +/// Renders a `FAILED` operation's `error` payload (name/message) for the +/// user-facing error, e.g. `" (DOMAIN_UNAVAILABLE: the domain is already +/// registered)"`. Empty when the operation carried no error detail (e.g. it +/// failed before an operation with a populated `error` was ever polled). +pub(crate) fn format_operation_error(error: Option<&types::Error>) -> String { + let Some(error) = error else { + return String::new(); + }; + match (error.name.as_deref(), error.message.as_deref()) { + (Some(name), Some(message)) => format!(" ({name}: {message})"), + (Some(name), None) => format!(" ({name})"), + (None, Some(message)) => format!(" ({message})"), + (None, None) => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_status_detection() { + assert!(is_terminal_status("COMPLETED")); + assert!(is_terminal_status("FAILED")); + assert!(!is_terminal_status("CONFIRMED")); + assert!(!is_terminal_status("EXECUTING")); + assert!(!is_terminal_status("SUBMITTED")); + } + + #[test] + fn format_operation_error_includes_name_and_message() { + assert_eq!(format_operation_error(None), ""); + + let name_only = types::Error { + name: Some("DOMAIN_UNAVAILABLE".to_string()), + ..Default::default() + }; + assert_eq!( + format_operation_error(Some(&name_only)), + " (DOMAIN_UNAVAILABLE)" + ); + + let message_only = types::Error { + message: Some("the domain is already registered".to_string()), + ..Default::default() + }; + assert_eq!( + format_operation_error(Some(&message_only)), + " (the domain is already registered)" + ); + + let both = types::Error { + name: Some("DOMAIN_UNAVAILABLE".to_string()), + message: Some("the domain is already registered".to_string()), + ..Default::default() + }; + assert_eq!( + format_operation_error(Some(&both)), + " (DOMAIN_UNAVAILABLE: the domain is already registered)" + ); + + let neither = types::Error::default(); + assert_eq!(format_operation_error(Some(&neither)), ""); + } + +} diff --git a/rust/src/domain/common/pricing.rs b/rust/src/domain/common/pricing.rs new file mode 100644 index 00000000..e2fa12b4 --- /dev/null +++ b/rust/src/domain/common/pricing.rs @@ -0,0 +1,194 @@ +use domains_client::types; + +/// The ISO-4217 minor-unit exponent for a currency — how many implied decimal +/// places a [`types::SimpleMoney`] `value` carries (the v3 spec defers money +/// formatting to ISO 4217). Sourced from the `iso_currency` crate's maintained +/// ISO 4217 dataset rather than a hand table, so it stays complete and correct +/// (JPY → 0, USD → 2, KWD → 3, CLF → 4, …). +/// +/// Falls back to 2 for an unrecognized code and for the codes ISO marks with no +/// minor unit (precious metals `XAU`/`XAG`, the IMF SDR `XDR`, `XXX`, test codes) +/// — none of which are spendable currencies that could be a domain price. +fn currency_decimals(code: &str) -> u32 { + iso_currency::Currency::from_code(&code.to_ascii_uppercase()) + .and_then(|c| c.exponent()) + .map_or(2, u32::from) +} + +/// Render a [`types::SimpleMoney`] as a decimal string. v3 money `value`s are in +/// ISO-4217 minor units for the currency (e.g. USD `1199` → `"11.99"`, JPY `1500` +/// → `"1500"`, BHD `1234` → `"1.234"`), NOT the micro-units v1 used. Truncates +/// toward zero (registry prices are whole minor units in practice); the sign is +/// explicit and `unsigned_abs` avoids `i64::MIN` overflow. `None` when the amount +/// is absent. Missing currency defaults to 2 decimals. +pub(crate) fn format_money(money: &types::SimpleMoney) -> Option { + let value = money.value?; + let code = money + .currency_code + .as_ref() + .map(|c| c.as_str()) + .unwrap_or(""); + let decimals = currency_decimals(code); + let sign = if value < 0 { "-" } else { "" }; + let abs = value.unsigned_abs(); + if decimals == 0 { + return Some(format!("{sign}{abs}")); + } + let scale = 10u64.pow(decimals); + Some(format!( + "{sign}{}.{:0width$}", + abs / scale, + abs % scale, + width = decimals as usize + )) +} + +/// The entry for a specific year-term period (1, 2, …), or `None` when that term +/// isn't in the list. Used by `suggest` to flatten multiple terms into scalar +/// per-period fields (e.g. `price1Year`, `price2Year`). +pub(crate) fn term_for_period( + prices: &[types::TermPrice], + period: u64, +) -> Option<&types::TermPrice> { + let period = std::num::NonZeroU64::new(period)?; + prices.iter().find(|p| p.period == Some(period)) +} + +/// Sorted, unique registration periods (years) priced in an availability response. +pub(crate) fn periods_from_prices(prices: &[types::TermPrice]) -> Vec { + let mut periods: Vec = prices + .iter() + .filter_map(|t| t.period.map(|p| p.get())) + .collect(); + periods.sort_unstable(); + periods.dedup(); + periods +} + +/// Indicative total registration price keyed by period years. +pub(crate) fn period_price_map( + prices: &[types::TermPrice], +) -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + for term in prices { + let Some(period) = term.period.map(|p| p.get()) else { + continue; + }; + if let Some(price) = term.price.as_ref().and_then(format_money) { + map.insert(period, price); + } + } + map +} + +/// Whether an API error body indicates the requested registration period exceeds +/// the TLD limit. Availability pricing is indicative; quote is authoritative. +pub(crate) fn is_period_limit_error(body: &str) -> bool { + let lower = body.to_ascii_lowercase(); + lower.contains("not currently supported") || lower.contains("maximum is") +} + +/// Parse the maximum registration period from a period-limit error body. +pub(crate) fn parse_max_registration_period(body: &str) -> Option { + let lower = body.to_ascii_lowercase(); + let needle = "maximum is "; + let rest = lower.split(needle).nth(1)?; + rest.split_whitespace().next()?.parse().ok() +} + +/// Drop unsupported periods and clamp the selected period to the TLD maximum. +pub(crate) fn clamp_registration_periods( + available_periods: &mut Vec, + selected_period: &mut u64, + max_years: u64, +) { + available_periods.retain(|p| *p <= max_years); + if available_periods.is_empty() { + available_periods.push(1); + } + if *selected_period > max_years { + *selected_period = available_periods.iter().copied().max().unwrap_or(1); + } +} + +/// A registration length with its unit spelled out ("1 year", "2 years") — a +/// bare number reads ambiguously in a table, so `quote`/`available` show this +/// alongside the numeric `period` field (which stays a plain number for +/// scripting against `--output json`). +pub(crate) fn period_label(period: u64) -> String { + if period == 1 { + "1 year".to_owned() + } else { + format!("{period} years") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domains_client::types; + + #[test] + fn periods_from_prices_returns_sorted_unique_periods() { + use domains_client::types; + + let prices = vec![ + types::TermPrice { + period: std::num::NonZeroU64::new(3), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(1), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(2), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(2), + ..Default::default() + }, + ]; + assert_eq!(periods_from_prices(&prices), vec![1, 2, 3]); + } + + #[test] + fn period_price_map_formats_indicative_totals() { + use domains_client::types; + + let prices = vec![types::TermPrice { + period: std::num::NonZeroU64::new(2), + price: Some(types::SimpleMoney { + value: Some(6098), + currency_code: Some(types::CurrencyCode("USD".to_string())), + }), + ..Default::default() + }]; + let map = period_price_map(&prices); + assert_eq!(map.get(&2).map(String::as_str), Some("60.98")); + } + + #[test] + fn parse_max_registration_period_reads_api_error_text() { + let body = r#"{"details":[{"description":"period 5 is not currently supported; maximum is 3 years"}]}"#; + assert!(is_period_limit_error(body)); + assert_eq!(parse_max_registration_period(body), Some(3)); + } + + #[test] + fn clamp_registration_periods_drops_and_clamps_selection() { + let mut periods = vec![1_u64, 2, 3, 5]; + let mut selected = 5_u64; + clamp_registration_periods(&mut periods, &mut selected, 3); + assert_eq!(periods, vec![1, 2, 3]); + assert_eq!(selected, 3); + } + #[test] + fn period_label_pluralizes_correctly() { + assert_eq!(period_label(1), "1 year"); + assert_eq!(period_label(2), "2 years"); + assert_eq!(period_label(10), "10 years"); + } + +} diff --git a/rust/src/domain/common/validation.rs b/rust/src/domain/common/validation.rs new file mode 100644 index 00000000..9ec6dc8d --- /dev/null +++ b/rust/src/domain/common/validation.rs @@ -0,0 +1,253 @@ +use cli_engine::{CliCoreError, Result}; + +/// Validate that `raw` (trimmed) is syntactically a valid domain name. Rejects +/// the shapes from DEVEX-885's bug report — embedded whitespace, null bytes, a +/// "protocol://" prefix, a "/path" suffix — via `url::Host::parse`'s WHATWG +/// forbidden-host-code-point + IDNA checks, then layers RFC 1035/1123 shape +/// rules a real domain always satisfies: not a bare IP literal, >=2 +/// dot-separated LDH labels, each 1-63 bytes, total <=253 bytes, and a TLD +/// that isn't all-numeric (ICANN disallows those). Returns the original +/// trimmed input — not `Host::parse`'s ASCII/punycode form — so an +/// already-working Unicode domain's wire format is unchanged. +pub(crate) fn validate_domain_name(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("domain name is required")); + } + // `url::Host::parse`'s error `Display` is a single generic string + // ("invalid international domain name") regardless of what's actually + // wrong (a space, a "://" prefix, a "/path" suffix, ...), so it isn't + // worth including — it would just read as a confusing non-sequitur. + let host = url::Host::parse(trimmed) + .map_err(|_| CliCoreError::message(format!("{trimmed:?} is not a valid domain name")))?; + let url::Host::Domain(ascii) = host else { + return Err(CliCoreError::message(format!( + "{trimmed:?} is an IP address, not a domain name" + ))); + }; + let labels: Vec<&str> = ascii.split('.').collect(); + let shape_ok = labels.len() >= 2 + && ascii.len() <= 253 + && labels.iter().all(|l| is_ldh_label(l)) + && !labels + .last() + .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())); + if !shape_ok { + return Err(CliCoreError::message(format!( + "{trimmed:?} doesn't look like a valid domain name (expected something like example.com)" + ))); + } + Ok(trimmed.to_owned()) +} + +/// Normalize and validate a TLD label for `--tld` / `--tlds` flags. +/// +/// Accepts `com` or `.com` (leading dot stripped), lowercases, and validates +/// each dot-separated label as LDH. +pub(crate) fn validate_tld(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("TLD is required")); + } + let stripped = trimmed.trim_start_matches('.'); + if stripped.is_empty() { + return Err(CliCoreError::message( + "TLD is required (expected something like com, not just a leading dot)", + )); + } + let lower = stripped.to_ascii_lowercase(); + let labels: Vec<&str> = lower.split('.').collect(); + if labels.iter().any(|label| !is_ldh_label(label)) { + return Err(CliCoreError::message(format!( + "{raw:?} doesn't look like a valid TLD (expected something like com or co.uk, without a leading dot)" + ))); + } + if labels + .last() + .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())) + { + return Err(CliCoreError::message(format!("{raw:?} is not a valid TLD"))); + } + Ok(lower) +} +/// A single RFC 1035/1123 "LDH label": 1-63 bytes, alphanumeric, interior +/// hyphens only (not leading/trailing). +fn is_ldh_label(label: &str) -> bool { + let bytes = label.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 63 + && bytes[0] != b'-' + && bytes[bytes.len() - 1] != b'-' + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'-') +} +/// Validate a quote token string (non-empty). +pub(crate) fn validate_quote_token(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("quote token is required")); + } + Ok(trimmed.to_owned()) +} +/// Normalize and validate an async operation ID (UUID). +pub(crate) fn validate_operation_id(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("operation ID is required")); + } + uuid::Uuid::parse_str(trimmed).map_err(|_| { + CliCoreError::message(format!( + "{trimmed:?} doesn't look like a valid operation ID (expected a UUID)" + )) + })?; + Ok(trimmed.to_owned()) +} +/// Validate every value of a repeatable `--nameserver`-style flag as a +/// domain-shaped hostname, wrapping [`validate_domain_name`]'s generic error +/// with the flag's own name — used by both `quote` (the registration +/// profile's nameservers) and `nameservers set` (the target domain's +/// nameservers) so a bad host isn't reported as if it were some other +/// (already-valid) domain argument. +pub(crate) fn validate_nameserver_hosts(raw: Vec) -> Result> { + raw.into_iter() + .map(|h| { + validate_domain_name(&h).map_err(|e| CliCoreError::message(format!("--nameserver {e}"))) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_domain_name_rejects_devex_885_repro_cases() { + // Every case from the DEVEX-885 bug report except the trailing-space + // one (which is accepted-after-trim; see the test below). + for bad in [ + "not a domain", + "https://test.com", + "test.com/page", + "test\u{0}evil.com", + ] { + assert!( + validate_domain_name(bad).is_err(), + "{bad:?} should be rejected" + ); + } + } + + #[test] + fn validate_domain_name_trims_whitespace_instead_of_rejecting() { + assert_eq!( + validate_domain_name("test.com ").expect("trimmed to valid"), + "test.com" + ); + assert_eq!( + validate_domain_name(" test.com").expect("trimmed to valid"), + "test.com" + ); + } + + #[test] + fn validate_domain_name_rejects_empty_or_whitespace_only() { + assert!(validate_domain_name("").is_err()); + assert!(validate_domain_name(" ").is_err()); + } + + #[test] + fn validate_domain_name_rejects_ip_literals() { + assert!(validate_domain_name("1.2.3.4").is_err()); + assert!(validate_domain_name("::1").is_err()); + } + + #[test] + fn validate_domain_name_rejects_missing_or_numeric_tld() { + assert!(validate_domain_name("example").is_err()); + assert!(validate_domain_name("example.123").is_err()); + } + + #[test] + fn validate_tld_strips_leading_dot_and_lowercases() { + assert_eq!(validate_tld(".org").expect("valid"), "org"); + assert_eq!(validate_tld("COM").expect("valid"), "com"); + assert_eq!(validate_tld("co.uk").expect("valid"), "co.uk"); + } + + #[test] + fn validate_tld_rejects_empty_or_dot_only() { + assert!(validate_tld("").is_err()); + assert!(validate_tld(".").is_err()); + assert!(validate_tld(" ").is_err()); + } + #[test] + fn validate_operation_id_accepts_uuid() { + assert_eq!( + validate_operation_id("550e8400-e29b-41d4-a716-446655440000").expect("valid"), + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn validate_operation_id_rejects_garbage() { + assert!(validate_operation_id("not-a-uuid").is_err()); + assert!(validate_operation_id("").is_err()); + } + + #[test] + fn validate_domain_name_rejects_leading_or_trailing_hyphen_labels() { + assert!(validate_domain_name("-example.com").is_err()); + assert!(validate_domain_name("example-.com").is_err()); + } + + #[test] + fn validate_domain_name_accepts_well_formed_domains_unchanged() { + for good in ["example.com", "xn--fsq.com", "ns1.example.co.uk"] { + assert_eq!(validate_domain_name(good).expect("valid domain"), good); + } + } + + #[test] + fn validate_domain_name_accepts_unicode_and_returns_original_not_punycode() { + // A real Unicode domain must pass (IDNA-processed for the shape check) + // but the returned string is the user's original input, not + // `Host::parse`'s ASCII/punycode form — no wire-format change for + // domains that already work today. + let input = "café.com"; + assert_eq!( + validate_domain_name(input).expect("valid unicode domain"), + input + ); + } + + #[test] + fn validate_nameserver_hosts_rejects_bad_shape_with_flag_context() { + // Regression: `quote`'s and `nameservers set`'s `--nameserver` values + // must go through the same shape check as a domain arg, but the error + // must say `--nameserver`, not claim the bad value is "the domain". + let err = validate_nameserver_hosts(vec!["bad ns".to_string()]) + .expect_err("malformed host should be rejected"); + let msg = err.to_string(); + assert!(msg.starts_with("--nameserver "), "{msg}"); + assert!(msg.contains("bad ns"), "{msg}"); + } + + #[test] + fn validate_nameserver_hosts_passes_through_valid_hosts_unchanged() { + let hosts = vec!["ns1.example.com".to_string(), "ns2.example.com".to_string()]; + assert_eq!( + validate_nameserver_hosts(hosts.clone()).expect("valid hosts"), + hosts + ); + } + + #[test] + fn validate_nameserver_hosts_empty_list_stays_empty() { + assert_eq!( + validate_nameserver_hosts(Vec::new()).expect("empty is valid"), + Vec::::new() + ); + } + +} From 098ab58408602fcedfc8a7ad5032004d79038952 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 1 Sep 2026 15:36:56 -0400 Subject: [PATCH 23/24] style(domain): apply rustfmt to common module split Co-authored-by: Cursor --- rust/src/domain/common/errors.rs | 1 - rust/src/domain/common/mod.rs | 2 -- rust/src/domain/common/operation.rs | 1 - rust/src/domain/common/pricing.rs | 1 - rust/src/domain/common/validation.rs | 1 - 5 files changed, 6 deletions(-) diff --git a/rust/src/domain/common/errors.rs b/rust/src/domain/common/errors.rs index 02f0659b..c07365b4 100644 --- a/rust/src/domain/common/errors.rs +++ b/rust/src/domain/common/errors.rs @@ -309,5 +309,4 @@ mod tests { ); assert!(!msg.contains("some fields are invalid"), "{msg}"); } - } diff --git a/rust/src/domain/common/mod.rs b/rust/src/domain/common/mod.rs index 51e96699..a2f96d62 100644 --- a/rust/src/domain/common/mod.rs +++ b/rust/src/domain/common/mod.rs @@ -2,7 +2,6 @@ //! client, money formatting, argument helpers, and API-error rendering. Each //! `gddy domain` subcommand lives in its own sibling module and draws from here. - mod client; mod errors; mod interactive; @@ -69,5 +68,4 @@ mod tests { fn comma_joined_empty_stays_empty() { assert_eq!(comma_joined(Vec::::new()), Vec::::new()); } - } diff --git a/rust/src/domain/common/operation.rs b/rust/src/domain/common/operation.rs index f1eca8ae..5dd77e9f 100644 --- a/rust/src/domain/common/operation.rs +++ b/rust/src/domain/common/operation.rs @@ -71,5 +71,4 @@ mod tests { let neither = types::Error::default(); assert_eq!(format_operation_error(Some(&neither)), ""); } - } diff --git a/rust/src/domain/common/pricing.rs b/rust/src/domain/common/pricing.rs index e2fa12b4..4f6b8a1f 100644 --- a/rust/src/domain/common/pricing.rs +++ b/rust/src/domain/common/pricing.rs @@ -190,5 +190,4 @@ mod tests { assert_eq!(period_label(2), "2 years"); assert_eq!(period_label(10), "10 years"); } - } diff --git a/rust/src/domain/common/validation.rs b/rust/src/domain/common/validation.rs index 9ec6dc8d..3f4601d2 100644 --- a/rust/src/domain/common/validation.rs +++ b/rust/src/domain/common/validation.rs @@ -249,5 +249,4 @@ mod tests { Vec::::new() ); } - } From 23b24cee596902de3db543b153fd15c785bd94b2 Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Tue, 1 Sep 2026 15:44:57 -0400 Subject: [PATCH 24/24] fix(domain): remove duplicate types import in pricing tests Co-authored-by: Cursor --- rust/src/domain/common/pricing.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rust/src/domain/common/pricing.rs b/rust/src/domain/common/pricing.rs index 4f6b8a1f..0c4fd0b9 100644 --- a/rust/src/domain/common/pricing.rs +++ b/rust/src/domain/common/pricing.rs @@ -126,7 +126,6 @@ pub(crate) fn period_label(period: u64) -> String { #[cfg(test)] mod tests { use super::*; - use domains_client::types; #[test] fn periods_from_prices_returns_sorted_unique_periods() {