From fc057903927d99329e037dbb6bceb25838216d5f Mon Sep 17 00:00:00 2001 From: Rajkumar TS Date: Wed, 19 Aug 2026 08:45:06 -0400 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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!(