diff --git a/rust/src/actions_catalog/mod.rs b/rust/src/actions_catalog/mod.rs index 9068e48..8da09ee 100644 --- a/rust/src/actions_catalog/mod.rs +++ b/rust/src/actions_catalog/mod.rs @@ -1,9 +1,11 @@ use cli_engine::{ - CommandResult, CommandSpec, GroupSpec, Module, NextAction, NextActionParam, RuntimeCommandSpec, + CommandResult, CommandSpec, GroupSpec, Module, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, Stage, Tier, }; use serde_json::json; +use crate::next_action::next_action; + /// Static catalog of available GoDaddy action names. /// Loaded from the manifest at compile time. const ACTIONS: &[(&str, &str)] = &[ @@ -87,7 +89,7 @@ pub fn module() -> Module { .map(|(name, description)| json!({ "name": name, "description": description })) .collect(); Ok(CommandResult::new(json!({ "actions": actions })).with_next_actions(vec![ - NextAction::new("actions describe ", "See an action's full schema") + next_action("actions describe ", "See an action's full schema") .with_param("action", NextActionParam::required()), ])) }, diff --git a/rust/src/api_explorer/mod.rs b/rust/src/api_explorer/mod.rs index ad15cdc..9bb9297 100644 --- a/rust/src/api_explorer/mod.rs +++ b/rust/src/api_explorer/mod.rs @@ -1,12 +1,13 @@ use std::sync::OnceLock; use cli_engine::{ - CommandResult, CommandSpec, GroupSpec, Module, NextAction, NextActionParam, RuntimeCommandSpec, + CommandResult, CommandSpec, GroupSpec, Module, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; use serde::Deserialize; use serde_json::{Value, json}; +use crate::next_action::next_action; use crate::output_schema::output_schema; output_schema!(ApiDomain { @@ -304,12 +305,12 @@ fn domain_list_command() -> RuntimeCommandSpec { }) .collect(); Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ - NextAction::new( + next_action( "api endpoint list --domain ", "List endpoints in a specific domain", ) .with_param("domain", NextActionParam::required()), - NextAction::new("api search ", "Search across all endpoints"), + next_action("api search ", "Search across all endpoints"), ])) }, ) @@ -362,7 +363,7 @@ fn endpoint_list_command() -> RuntimeCommandSpec { }) .collect(); Ok(CommandResult::new(json!(endpoints)).with_next_actions(vec![ - NextAction::new( + next_action( "api describe ", "Get full details for an endpoint", ) @@ -419,7 +420,7 @@ fn describe_command() -> RuntimeCommandSpec { "scopes": ep.scopes, })) .with_next_actions(vec![ - NextAction::new( + next_action( format!("api call {} --method {}", ep.path, ep.method), "Make an authenticated call to this endpoint", ), @@ -467,7 +468,7 @@ fn search_command() -> RuntimeCommandSpec { }) .collect(); Ok(CommandResult::new(json!(results)).with_next_actions(vec![ - NextAction::new( + next_action( "api describe ", "Get full details for a result", ) diff --git a/rust/src/application/commands/mod.rs b/rust/src/application/commands/mod.rs index b4dadb9..d558f0e 100644 --- a/rust/src/application/commands/mod.rs +++ b/rust/src/application/commands/mod.rs @@ -1,10 +1,11 @@ use cli_engine::{ - CommandResult, CommandSpec, GroupSpec, NextAction, NextActionParam, RuntimeCommandSpec, - RuntimeGroupSpec, StreamSender, Tier, + CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, + StreamSender, Tier, }; use serde_json::json; use crate::application::client::{ApplicationClient, api_url_for_env}; +use crate::next_action::next_action; use crate::output_schema::output_schema; // App-registry mutations declare their scopes so `apps.app-registry:write` is // requested on demand (OAuth step-up), not granted at every login — it's a @@ -151,12 +152,12 @@ fn list_command() -> RuntimeCommandSpec { let client = make_client(&ctx).await?; let data = client.list_applications().await.map_err(client_err)?; Ok(CommandResult::new(data).with_next_actions(vec![ - NextAction::new( + next_action( "application info --name ", "Get details for a specific application", ) .with_param("name", NextActionParam::required()), - NextAction::new( + next_action( "application init --name --description --url ", "Initialize a new application", ), @@ -190,11 +191,11 @@ fn info_command() -> RuntimeCommandSpec { let data = client.get_application(&name).await.map_err(client_err)?; Ok( CommandResult::new(data["application"].clone()).with_next_actions(vec![ - NextAction::new( + next_action( "application release --application-id --version ", "Create a release", ), - NextAction::new( + next_action( format!("application deploy --name {name}"), "Deploy this application", ) @@ -206,7 +207,7 @@ fn info_command() -> RuntimeCommandSpec { ..Default::default() }, ), - NextAction::new( + next_action( "application update --id ", "Update application metadata", ), @@ -328,15 +329,15 @@ fn init_command() -> RuntimeCommandSpec { .map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?; Ok(CommandResult::new(app.clone()).with_next_actions(vec![ - NextAction::new( + next_action( "application add action --name --url ", "Add an action to godaddy.toml", ), - NextAction::new( + next_action( "application validate", "Validate the generated godaddy.toml", ), - NextAction::new( + next_action( "application release --application-id --version 0.0.1", "Create the first release", ), @@ -434,7 +435,7 @@ fn update_command() -> RuntimeCommandSpec { .to_owned(); Ok( CommandResult::new(data["updateApplication"].clone()).with_next_actions(vec![ - NextAction::new( + next_action( format!("application info --name {name}"), "Inspect updated application", ) @@ -446,7 +447,7 @@ fn update_command() -> RuntimeCommandSpec { ..Default::default() }, ), - NextAction::new( + next_action( format!("application deploy --name {name}"), "Deploy updated application", ) @@ -499,7 +500,7 @@ fn enable_command() -> RuntimeCommandSpec { .map_err(client_err)?; Ok( CommandResult::new(data["enableStoreApplication"].clone()).with_next_actions(vec![ - NextAction::new( + next_action( format!("application disable {name} --store-id {store_id}"), "Disable the application on the same store", ) @@ -519,7 +520,7 @@ fn enable_command() -> RuntimeCommandSpec { ..Default::default() }, ), - NextAction::new( + next_action( format!("application info --name {name}"), "Inspect application", ) @@ -573,7 +574,7 @@ fn disable_command() -> RuntimeCommandSpec { Ok( CommandResult::new(data["disableStoreApplication"].clone()).with_next_actions( vec![ - NextAction::new( + next_action( format!("application enable {name} --store-id {store_id}"), "Re-enable the application on the same store", ) @@ -593,7 +594,7 @@ fn disable_command() -> RuntimeCommandSpec { ..Default::default() }, ), - NextAction::new( + next_action( format!("application info --name {name}"), "Inspect application", ) @@ -646,7 +647,7 @@ fn archive_command() -> RuntimeCommandSpec { .map_err(client_err)?; Ok( CommandResult::new(data["archiveApplication"].clone()).with_next_actions(vec![ - NextAction::new("application list", "List remaining applications"), + next_action("application list", "List remaining applications"), ]), ) }, @@ -702,8 +703,8 @@ fn release_command() -> RuntimeCommandSpec { let data = client.create_release(input).await.map_err(client_err)?; Ok( CommandResult::new(data["createRelease"].clone()).with_next_actions(vec![ - NextAction::new("application deploy --name ", "Deploy this release"), - NextAction::new("application info --name ", "Inspect application"), + next_action("application deploy --name ", "Deploy this release"), + next_action("application info --name ", "Inspect application"), ]), ) }, diff --git a/rust/src/dns/mod.rs b/rust/src/dns/mod.rs index 6cbf10a..def9e3e 100644 --- a/rust/src/dns/mod.rs +++ b/rust/src/dns/mod.rs @@ -25,6 +25,7 @@ use cli_engine::{ use serde_json::{Value, json}; use crate::domain::{api_error, make_client, string_list}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::{DOMAINS_DNS_UPDATE, DOMAINS_READ}; @@ -351,7 +352,7 @@ fn summarize_delete_outcomes( /// Next action pointing back at `dns list` to verify a write, pre-filled with /// the domain/type/name the write just touched. fn verify_with_list_action(domain: &str, record_type: &str, name: &str) -> NextAction { - NextAction::new( + next_action( "dns list --type --name ", "Verify the records for this type+name", ) diff --git a/rust/src/domain/agreements.rs b/rust/src/domain/agreements.rs index ddde0c4..1019296 100644 --- a/rust/src/domain/agreements.rs +++ b/rust/src/domain/agreements.rs @@ -1,13 +1,14 @@ //! `gddy domain agreements` — the legal agreements a TLD requires (v1). use cli_engine::{ - CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, + CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, }; use serde_json::json; use domains_client::types; use super::common::{api_error, make_client, string_list}; +use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; pub(super) fn command() -> RuntimeCommandSpec { @@ -72,12 +73,12 @@ pub(super) fn command() -> RuntimeCommandSpec { .collect(); Ok( CommandResult::new(json!(agreements)).with_next_actions(vec![ - NextAction::new( + next_action( "domain quote ", "Price a registration and see the agreements for a specific domain", ) .with_param("domain", NextActionParam::required()), - NextAction::new( + next_action( "domain purchase --quote-token --agree --confirm", "Register once you have a quote", ) diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index e0caa28..220bf3d 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -1,13 +1,12 @@ //! `gddy domain available` — check whether a domain can be registered (v3). -use cli_engine::{ - CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, -}; +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::json; use domains_client::types; use super::common::{api_error, format_money, headline_price, make_client}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -107,12 +106,12 @@ pub(super) fn command() -> RuntimeCommandSpec { let cmd = CommandResult::new(result); if body.available.unwrap_or(false) { Ok(cmd.with_next_actions(vec![ - NextAction::new("domain quote ", "Price a registration") + next_action("domain quote ", "Price a registration") .with_param("domain", NextActionParam::required()), ])) } else { Ok(cmd.with_next_actions(vec![ - NextAction::new("domain suggest ", "Find alternatives") + next_action("domain suggest ", "Find alternatives") .with_param("query", NextActionParam::required()), ])) } diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index 79155ae..372ee4c 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -58,8 +58,8 @@ pub(super) fn format_money(money: &types::SimpleMoney) -> Option { } /// The headline price among per-term prices: the 1-year term if present, else the -/// first listed term. Shared by `available` and `suggest`, which both surface a -/// single indicative price from a `TermPrice` array. +/// first listed term. Used by `available`, which surfaces a single indicative +/// price from a `TermPrice` array. pub(super) fn headline_price(prices: &[types::TermPrice]) -> Option<&types::TermPrice> { let one_year = std::num::NonZeroU64::new(1); prices @@ -68,6 +68,17 @@ pub(super) fn headline_price(prices: &[types::TermPrice]) -> Option<&types::Term .or_else(|| prices.first()) } +/// The entry for a specific year-term period (1, 2, …), or `None` when that term +/// isn't in the list. Used by `suggest` to flatten multiple terms into scalar +/// per-period fields (e.g. `price1Year`, `price2Year`). +pub(super) fn term_for_period( + prices: &[types::TermPrice], + period: u64, +) -> Option<&types::TermPrice> { + let period = std::num::NonZeroU64::new(period)?; + prices.iter().find(|p| p.period == Some(period)) +} + /// Whether an async domain-operation status is terminal (no further polling). /// Only `COMPLETED`/`FAILED` are terminal; every other status (e.g. `SUBMITTED`, /// `PENDING`, `CONFIRMED`, `EXECUTING`) is treated as still in progress. diff --git a/rust/src/domain/contacts.rs b/rust/src/domain/contacts.rs index ad2734c..1a213b2 100644 --- a/rust/src/domain/contacts.rs +++ b/rust/src/domain/contacts.rs @@ -2,12 +2,12 @@ //! `domain quote`/`purchase`. This is a local-file command group; no API/auth. use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, GroupSpec, NextAction, RuntimeCommandSpec, - RuntimeGroupSpec, Tier, + CliCoreError, CommandResult, CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; use serde_json::json; use crate::contacts; +use crate::next_action::next_action; pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( @@ -64,7 +64,7 @@ pub(super) fn group() -> RuntimeGroupSpec { "path": path.display().to_string(), "action": if existed { "overwritten" } else { "created" }, })) - .with_next_actions(vec![NextAction::new( + .with_next_actions(vec![next_action( "guide domain-purchase", "Learn how purchase uses these contacts", )])) diff --git a/rust/src/domain/get.rs b/rust/src/domain/get.rs index 0c06535..b57d187 100644 --- a/rust/src/domain/get.rs +++ b/rust/src/domain/get.rs @@ -1,12 +1,13 @@ //! `gddy domain get` — show full details for one owned domain (v3). use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, + CliCoreError, CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier, }; use domains_client::types; use super::common::{api_error, make_client}; +use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; pub(super) fn command() -> RuntimeCommandSpec { @@ -49,7 +50,7 @@ pub(super) fn command() -> RuntimeCommandSpec { CliCoreError::message(format!("failed to serialize domain details: {e}")) })?; Ok(CommandResult::new(value).with_next_actions(vec![ - NextAction::new("dns list ", "View this domain's DNS records") + next_action("dns list ", "View this domain's DNS records") .with_param("domain", NextActionParam::required()), ])) }, diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index 1d494cd..1b875eb 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -1,14 +1,14 @@ //! `gddy domain list` — list the domains in the account (v1). use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, NextAction, NextActionParam, Result, - RuntimeCommandSpec, Tier, + CliCoreError, CommandResult, CommandSpec, NextActionParam, Result, RuntimeCommandSpec, Tier, }; use serde_json::json; use domains_client::types; use super::common::{api_error, make_client, string_list}; +use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; /// Validate `--status` values case-insensitively against the generated @@ -63,7 +63,7 @@ pub(super) fn command() -> RuntimeCommandSpec { CliCoreError::message(format!("failed to serialize domain list: {e}")) })?; Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ - NextAction::new("dns list ", "View a domain's DNS records") + next_action("dns list ", "View a domain's DNS records") .with_param("domain", NextActionParam::required()), ])) }, diff --git a/rust/src/domain/nameservers.rs b/rust/src/domain/nameservers.rs index 2c6c0c7..ca6b994 100644 --- a/rust/src/domain/nameservers.rs +++ b/rust/src/domain/nameservers.rs @@ -1,14 +1,15 @@ //! `gddy domain nameservers` — manage a domain's nameservers (v3). use cli_engine::{ - CommandResult, CommandSpec, GroupSpec, NextAction, NextActionParam, RuntimeCommandSpec, - RuntimeGroupSpec, Tier, + CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, + Tier, }; use serde_json::json; use domains_client::types; use super::common::{api_error, make_client, string_list}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_NAMESERVER_UPDATE; @@ -93,7 +94,7 @@ pub(super) fn group() -> RuntimeGroupSpec { result["status"] = json!(status.to_string()); } Ok(CommandResult::new(result).with_next_actions(vec![ - NextAction::new("domain get ", "Confirm the updated nameservers") + next_action("domain get ", "Confirm the updated nameservers") .with_param("domain", NextActionParam::value(domain)), ])) }, diff --git a/rust/src/domain/operation.rs b/rust/src/domain/operation.rs index b1eb868..2332f10 100644 --- a/rust/src/domain/operation.rs +++ b/rust/src/domain/operation.rs @@ -6,14 +6,15 @@ //! bounded poll loop already uses internally. use cli_engine::{ - CommandResult, CommandSpec, GroupSpec, NextAction, NextActionParam, RuntimeCommandSpec, - RuntimeGroupSpec, Tier, + CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, + Tier, }; use serde_json::json; use domains_client::types; use super::common::{api_error, format_operation_error, is_terminal_status, make_client}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -113,13 +114,13 @@ fn status_command() -> RuntimeCommandSpec { if is_terminal_status(&status) { if status == "COMPLETED" && !domain.is_empty() { actions.push( - NextAction::new("domain get ", "See the domain's details") + next_action("domain get ", "See the domain's details") .with_param("domain", NextActionParam::value(domain)), ); } } else { actions.push( - NextAction::new( + next_action( "domain operation status ", "Re-check whether the operation has finished", ) diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 9b0f357..2ee059d 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -1,7 +1,7 @@ //! `gddy domain purchase` — register a domain by accepting a cached quote (v3). use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, Credential, NextAction, NextActionParam, Result, + CliCoreError, CommandResult, CommandSpec, Credential, NextActionParam, Result, RuntimeCommandSpec, Tier, }; use serde_json::json; @@ -9,6 +9,7 @@ use serde_json::json; use domains_client::types; use super::common::{api_error, format_operation_error, is_terminal_status, make_client_with_cred}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::quote_cache; use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; @@ -406,14 +407,14 @@ pub(super) fn command() -> RuntimeCommandSpec { result["currency"] = json!(c); } let mut actions = vec![ - NextAction::new("domain get ", next_action_description(&status)) + next_action("domain get ", next_action_description(&status)) .with_param("domain", NextActionParam::required()), ]; if still_pending { // `still_pending` implies `operation_id.is_some()`. if let Some(op) = &operation_id { actions.push( - NextAction::new( + next_action( "domain operation status ", "Check whether registration has finished since polling gave up", ) diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index b03f812..26941ed 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -1,14 +1,14 @@ //! `gddy domain quote` — price a registration, lock a quote, cache it (v3). use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, NextAction, NextActionParam, Result, - RuntimeCommandSpec, Tier, + CliCoreError, CommandResult, CommandSpec, NextActionParam, Result, RuntimeCommandSpec, Tier, }; use serde_json::json; use domains_client::types; use super::common::{api_error, format_money, make_client, string_list}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; use crate::{contacts, quote_cache}; @@ -318,7 +318,7 @@ pub(super) fn command() -> RuntimeCommandSpec { tracing::warn!(error = %e, "could not cache the quote for purchase"); } next_actions.push( - NextAction::new( + next_action( "domain purchase --quote-token --agree --confirm", "Register at the quoted price (within ~10 minutes)", ) @@ -328,7 +328,7 @@ pub(super) fn command() -> RuntimeCommandSpec { // Not available (or no token was issued): point at discovery, the // same next step `domain available` offers for a taken name. next_actions.push( - NextAction::new("domain suggest ", "Find an available alternative") + next_action("domain suggest ", "Find an available alternative") .with_param("query", NextActionParam::required()), ); } diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index 16d9f0b..ca15e85 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -1,21 +1,26 @@ //! `gddy domain suggest` — suggest available domains for a query (v3). -use cli_engine::{ - CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, -}; +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::json; -use super::common::{api_error, format_money, headline_price, make_client, string_list}; +use domains_client::types; + +use super::common::{api_error, format_money, make_client, string_list, term_for_period}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; -// The handler emits a transformed per-item shape (`listPrice` is a formatted -// string from SimpleMoney, not the raw `types::Suggestion`), so declare the -// schema to match what's actually returned for `--schema`/help. +// The handler emits a transformed per-item shape (formatted price/renewal-price +// strings per term, not the raw `types::Suggestion`), so declare the schema to +// match what's actually returned for `--schema`/help. output_schema!(DomainSuggestResult { "domain": "string"; - // Present only when the API returns a formattable list price. - "listPrice": "string", optional; + // Present only when the API returns a formattable price for that term. + "price1Year": "string", optional; + "renewalPrice1Year": "string", optional; + "price2Year": "string", optional; + "renewalPrice2Year": "string", optional; + "currency": "string", optional; }); /// Convert a count-style flag value to the `NonZeroU64` the suggest query params @@ -26,6 +31,47 @@ fn nonzero(n: i64) -> Option { u64::try_from(n).ok().and_then(std::num::NonZeroU64::new) } +/// Build the JSON view of a single suggestion, flattening its 1- and 2-year +/// `TermPrice` entries into scalar price/renewal-price fields (v3 returns +/// indicative pricing as a `prices` array, which doesn't project into a table). +/// `None` when the suggestion has no domain, so every emitted object matches the +/// schema (`domain` required). +fn suggestion_to_json(s: &types::Suggestion) -> Option { + let domain = s.domain.as_deref()?; + let mut obj = json!({ "domain": domain }); + let prices = s.prices.as_deref().unwrap_or(&[]); + let term_1yr = term_for_period(prices, 1); + let term_2yr = term_for_period(prices, 2); + if let Some(t) = term_1yr { + if let Some(p) = t.price.as_ref().and_then(format_money) { + obj["price1Year"] = json!(p); + } + if let Some(r) = t.renewal_price.as_ref().and_then(format_money) { + obj["renewalPrice1Year"] = json!(r); + } + } + if let Some(t) = term_2yr { + if let Some(p) = t.price.as_ref().and_then(format_money) { + obj["price2Year"] = json!(p); + } + if let Some(r) = t.renewal_price.as_ref().and_then(format_money) { + obj["renewalPrice2Year"] = json!(r); + } + } + // Only emit `currency` when a code is present — never a JSON null (the + // schema declares it an optional string, and null leaks into tables). Falls + // back to `renewal_price`'s code when `price` is absent, so a renewal-only + // term still gets a currency. + let currency_code = term_1yr + .or(term_2yr) + .and_then(|t| t.price.as_ref().or(t.renewal_price.as_ref())) + .and_then(|m| m.currency_code.as_ref()); + if let Some(code) = currency_code { + obj["currency"] = json!(code.to_string()); + } + Some(obj) +} + pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_with_context( CommandSpec::new("suggest", "Suggest available domains for a query") @@ -37,7 +83,7 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("domain") .with_tier(Tier::Read) - .with_default_fields("domain") + .with_default_fields("domain,price1Year,renewalPrice1Year,currency") .with_output_schema::() .with_scopes(&[DOMAINS_READ]) .with_arg( @@ -103,31 +149,11 @@ pub(super) fn command() -> RuntimeCommandSpec { Ok(r) => r.into_inner(), Err(e) => return Err(api_error("domain suggestion", debug, e).await), }; - let suggestions: Vec = resp - .items - .into_iter() - .filter_map(|s| { - // Skip any suggestion without a domain so every emitted object - // matches the schema (`domain` required) and projects cleanly. - let domain = s.domain?; - let mut obj = json!({ "domain": domain }); - // v3 returns indicative pricing per term; surface the headline - // (1-year, else first) term's price as the scalar `listPrice`. - if let Some(p) = s - .prices - .as_deref() - .and_then(headline_price) - .and_then(|t| t.price.as_ref()) - .and_then(format_money) - { - obj["listPrice"] = json!(p); - } - Some(obj) - }) - .collect(); + let suggestions: Vec = + resp.items.iter().filter_map(suggestion_to_json).collect(); Ok( CommandResult::new(json!(suggestions)).with_next_actions(vec![ - NextAction::new("domain available ", "Check a suggested domain") + next_action("domain available ", "Check a suggested domain") .with_param("domain", NextActionParam::required()), ]), ) @@ -137,7 +163,8 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { - use super::nonzero; + use super::{nonzero, suggestion_to_json}; + use domains_client::types; #[test] fn nonzero_maps_zero_and_negative_to_none() { @@ -147,4 +174,68 @@ mod tests { assert_eq!(nonzero(-5), None); assert_eq!(nonzero(5).map(|n| n.get()), Some(5)); } + + fn money(value: i64, currency: &str) -> types::SimpleMoney { + types::SimpleMoney { + value: Some(value), + currency_code: Some(types::CurrencyCode(currency.to_string())), + } + } + + fn term(period: u64, price: i64, renewal: i64) -> types::TermPrice { + types::TermPrice { + period: std::num::NonZeroU64::new(period), + price: Some(money(price, "USD")), + renewal_price: Some(money(renewal, "USD")), + term: Some(types::Term::Year), + } + } + + #[test] + fn flattens_1_and_2_year_terms_into_scalar_fields() { + // Mirrors the API's example response for a suggestion with both terms. + let suggestion = types::Suggestion { + domain: Some("kirklandfreshtreats.shop".to_string()), + inventory: Some(types::InventoryType::Registry), + prices: Some(vec![term(1, 99, 3799), term(2, 6098, 7598)]), + }; + let obj = suggestion_to_json(&suggestion).expect("domain is present"); + assert_eq!(obj["domain"], "kirklandfreshtreats.shop"); + assert_eq!(obj["price1Year"], "0.99"); + assert_eq!(obj["renewalPrice1Year"], "37.99"); + assert_eq!(obj["price2Year"], "60.98"); + assert_eq!(obj["renewalPrice2Year"], "75.98"); + assert_eq!(obj["currency"], "USD"); + } + + #[test] + fn currency_falls_back_to_renewal_price_when_price_is_absent() { + // A term with only a renewal price (no indicative price) must still + // surface a currency code, sourced from the renewal price itself. + let renewal_only = types::TermPrice { + period: std::num::NonZeroU64::new(1), + price: None, + renewal_price: Some(money(3799, "USD")), + term: Some(types::Term::Year), + }; + let suggestion = types::Suggestion { + domain: Some("example.com".to_string()), + inventory: Some(types::InventoryType::Registry), + prices: Some(vec![renewal_only]), + }; + let obj = suggestion_to_json(&suggestion).expect("domain is present"); + assert_eq!(obj["renewalPrice1Year"], "37.99"); + assert!(obj.get("price1Year").is_none()); + assert_eq!(obj["currency"], "USD"); + } + + #[test] + fn no_domain_is_skipped() { + let suggestion = types::Suggestion { + domain: None, + inventory: None, + prices: None, + }; + assert!(suggestion_to_json(&suggestion).is_none()); + } } diff --git a/rust/src/hosting/nodejs/mod.rs b/rust/src/hosting/nodejs/mod.rs index 430f488..3f524f7 100644 --- a/rust/src/hosting/nodejs/mod.rs +++ b/rust/src/hosting/nodejs/mod.rs @@ -1,11 +1,12 @@ pub mod client; use cli_engine::{ - CliCoreError, CommandContext, CommandResult, CommandSpec, GroupSpec, NextAction, - NextActionParam, Result, RuntimeCommandSpec, RuntimeGroupSpec, Tier, + CliCoreError, CommandContext, CommandResult, CommandSpec, GroupSpec, NextActionParam, Result, + RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; use serde_json::{Value, json}; +use crate::next_action::next_action; use crate::{ application::client::api_url_for_env, hosting::nodejs::client::HostingClient, output_schema::output_schema, @@ -123,7 +124,7 @@ fn app_list_command() -> RuntimeCommandSpec { let client = make_client(&ctx, &[APPS_READ]).await?; let data = client.list_apps().await.map_err(client_err)?; Ok(CommandResult::new(data).with_next_actions(vec![ - NextAction::new( + next_action( "hosting nodejs app get --app-id ", "Get details for an application", ) @@ -152,12 +153,12 @@ fn app_get_command() -> RuntimeCommandSpec { let client = make_client(&ctx, &[APPS_READ]).await?; let data = client.get_app(&app_id).await.map_err(client_err)?; Ok(CommandResult::new(data).with_next_actions(vec![ - NextAction::new( + next_action( "hosting nodejs deployment list --app-id ", "List deployments for this application", ) .with_param("app-id", NextActionParam::value(app_id.clone())), - NextAction::new( + next_action( "hosting nodejs status --app-id ", "Get application status", ) @@ -205,14 +206,14 @@ fn app_create_command() -> RuntimeCommandSpec { .and_then(|v| v.as_str()) .unwrap_or(""); let mut actions = vec![ - NextAction::new( + next_action( "hosting nodejs job get --job-id ", "Poll app creation status", ) .with_param("job-id", NextActionParam::required()), ]; if !job_id.is_empty() { - actions[0] = NextAction::new( + actions[0] = next_action( "hosting nodejs job get --job-id ", "Poll app creation status", ) @@ -297,7 +298,7 @@ fn app_delete_command() -> RuntimeCommandSpec { client.delete_app(&app_id).await.map_err(client_err)?; Ok( CommandResult::new(json!({ "deleted": true, "appId": app_id })).with_next_actions( - vec![NextAction::new( + vec![next_action( "hosting nodejs app list", "List remaining applications", )], @@ -337,7 +338,7 @@ fn job_get_command() -> RuntimeCommandSpec { .and_then(|v| v.as_str()) .unwrap_or(""); let mut actions = vec![ - NextAction::new( + next_action( "hosting nodejs job get --job-id ", "Re-check job status", ) @@ -345,7 +346,7 @@ fn job_get_command() -> RuntimeCommandSpec { ]; if let Some(app_id) = data.pointer("/app/id").and_then(|v| v.as_str()) { actions.push( - NextAction::new( + next_action( "hosting nodejs app get --app-id ", "View the provisioned application", ) @@ -389,7 +390,7 @@ fn deployment_list_command() -> RuntimeCommandSpec { .await .map_err(client_err)?; Ok(CommandResult::new(data).with_next_actions(vec![ - NextAction::new( + next_action( "hosting nodejs deployment publish --app-id ", "Publish the latest source", ) @@ -422,12 +423,12 @@ fn deployment_publish_command() -> RuntimeCommandSpec { let client = make_client(&ctx, &[DEPLOY_EXECUTE]).await?; let data = client.publish_app(&app_id).await.map_err(client_err)?; Ok(CommandResult::new(data).with_next_actions(vec![ - NextAction::new( + next_action( "hosting nodejs status --app-id ", "Check application status", ) .with_param("app-id", NextActionParam::value(app_id.clone())), - NextAction::new( + next_action( "hosting nodejs deployment list --app-id ", "List deployments", ) @@ -456,12 +457,12 @@ fn status_command() -> RuntimeCommandSpec { let client = make_client(&ctx, &[APPS_READ]).await?; let data = client.get_app_status(&app_id).await.map_err(client_err)?; Ok(CommandResult::new(data).with_next_actions(vec![ - NextAction::new( + next_action( "hosting nodejs deployment list --app-id ", "List deployments for this application", ) .with_param("app-id", NextActionParam::value(app_id.clone())), - NextAction::new( + next_action( "hosting nodejs logs --app-id ", "View application logs", ) @@ -511,14 +512,14 @@ fn source_upload_command() -> RuntimeCommandSpec { .map_err(client_err)?; let upload_job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or(""); let action = if upload_job_id.is_empty() { - NextAction::new( + next_action( "hosting nodejs source status --app-id --job-id ", "Poll zip upload status", ) .with_param("app-id", NextActionParam::value(app_id)) .with_param("job-id", NextActionParam::required()) } else { - NextAction::new( + next_action( "hosting nodejs source status --app-id --job-id ", "Poll zip upload status", ) @@ -560,13 +561,13 @@ fn source_status_command() -> RuntimeCommandSpec { .await .map_err(client_err)?; Ok(CommandResult::new(data).with_next_actions(vec![ - NextAction::new( + next_action( "hosting nodejs source status --app-id --job-id ", "Poll again while upload is in progress", ) .with_param("app-id", NextActionParam::value(app_id.clone())) .with_param("job-id", NextActionParam::value(job_id)), - NextAction::new( + next_action( "hosting nodejs deployment publish --app-id ", "Publish after upload completes", ) diff --git a/rust/src/main.rs b/rust/src/main.rs index 2a82e1e..84248de 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -10,6 +10,7 @@ mod env; mod environments; mod extension; mod hosting; +mod next_action; mod output_schema; mod pat; mod payments; @@ -21,9 +22,10 @@ mod webhook; use std::{process::ExitCode, sync::Arc}; use clap::Arg; -use cli_engine::{BuildInfo, Cli, CliConfig, NextAction}; +use cli_engine::{BuildInfo, Cli, CliConfig}; use crate::env::get_env; +use crate::next_action::next_action; #[tokio::main] async fn main() -> ExitCode { @@ -86,10 +88,10 @@ async fn main() -> ExitCode { })) .with_root_next_actions(Arc::new(|| { vec![ - NextAction::new("auth status", "Check authentication status"), - NextAction::new("env get", "Get the current active environment"), - NextAction::new("application list", "List all applications"), - NextAction::new("tree", "Display the full command tree"), + next_action("auth status", "Check authentication status"), + next_action("env get", "Get the current active environment"), + next_action("application list", "List all applications"), + next_action("tree", "Display the full command tree"), ] })) .with_pre_run(Arc::new(|_mw, _path, _args| { diff --git a/rust/src/next_action.rs b/rust/src/next_action.rs new file mode 100644 index 0000000..f574453 --- /dev/null +++ b/rust/src/next_action.rs @@ -0,0 +1,17 @@ +//! Helper so every suggested next-step command reads exactly as the user would +//! type it (e.g. `gddy domain quote `, not `domain quote `). +//! +//! `cli_engine::NextAction` is a generic, app-agnostic type with no concept of a +//! binary name, so the `gddy` prefix is applied here in the main repo rather than +//! in the (separately versioned) `cli-engine` crate. + +use cli_engine::NextAction; + +use crate::environments::APP_ID; + +pub(crate) fn next_action( + command: impl Into, + description: impl Into, +) -> NextAction { + NextAction::new(format!("{APP_ID} {}", command.into()), description) +} diff --git a/rust/src/pat/mod.rs b/rust/src/pat/mod.rs index e2049a8..3c33d65 100644 --- a/rust/src/pat/mod.rs +++ b/rust/src/pat/mod.rs @@ -15,13 +15,14 @@ use std::collections::BTreeMap; use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, NextAction, RuntimeCommandSpec, + CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::environments; +use crate::next_action::next_action; use crate::output_schema::output_schema; output_schema!(PatListItem { @@ -364,7 +365,7 @@ fn add_command() -> RuntimeCommandSpec { "lastFour": last_four(&entry.token), "path": path.display().to_string(), })) - .with_next_actions(vec![NextAction::new("guide auth", "Learn about PAT auth")])) + .with_next_actions(vec![next_action("guide auth", "Learn about PAT auth")])) }, ) } diff --git a/rust/src/payments/mod.rs b/rust/src/payments/mod.rs index eceb7d2..751468b 100644 --- a/rust/src/payments/mod.rs +++ b/rust/src/payments/mod.rs @@ -1,12 +1,13 @@ //! `gddy payments` — payment method management. use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, NextAction, NextActionParam, + CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; use serde_json::json; use crate::environments; +use crate::next_action::next_action; fn map_env_err(e: environments::EnvError) -> CliCoreError { CliCoreError::message(e.to_string()) @@ -54,7 +55,7 @@ fn add_command() -> RuntimeCommandSpec { } })) .with_next_actions(vec![ - NextAction::new( + next_action( "domain quote ", "Price a domain registration now that a payment method is on file", )