From 42e3be6e86465e8c1d0948c9f5808ed799bc099c Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Mon, 15 Jun 2026 15:09:48 -0700 Subject: [PATCH 1/2] feat: add gddy payments add command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `gddy payments add` command that opens the system's default browser to the GoDaddy payment methods management page, with environment-aware URL derivation (prod → account.godaddy.com, others → account.{env}-godaddy.com). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- README.md | 5 +++ rust/Cargo.lock | 1 + rust/Cargo.toml | 1 + rust/src/main.rs | 2 + rust/src/payments/mod.rs | 82 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+) create mode 100644 rust/src/payments/mod.rs diff --git a/README.md b/README.md index b20edcc..210bc9c 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,11 @@ Returns environment/auth snapshots and the full command tree. - `gddy application add extension checkout --name --handle --source --target ` - `gddy application add extension blocks --source ` +### Payments + +- `gddy payments` +- `gddy payments add` — opens your default browser to the GoDaddy payment methods management page. Only credit card or Good-as-Gold can be used for domain purchases. + ### Webhooks - `gddy webhook` diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ab14156..880006f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1105,6 +1105,7 @@ dependencies = [ "domains-client", "fancy-regex", "httpmock", + "open", "regex", "reqwest", "serde", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index eb09ddb..4f240e8 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -20,6 +20,7 @@ cli-engine = { features = ["pkce-auth"], version = "0.2.2" } dirs = "6" domains-client = { path = "domains-client" } fancy-regex = "0.14" +open = "5" regex = { version = "1", features = ["std"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } sha2 = "0.10" diff --git a/rust/src/main.rs b/rust/src/main.rs index 907b111..8d3d8b8 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -7,6 +7,7 @@ mod domain; mod env; mod environments; mod extension; +mod payments; mod webhook; use std::{process::ExitCode, sync::Arc}; @@ -69,6 +70,7 @@ async fn main() -> ExitCode { .with_module(application::module()) .with_module(domain::module()) .with_module(env::module()) + .with_module(payments::module()) .with_module(webhook::module()), ); diff --git a/rust/src/payments/mod.rs b/rust/src/payments/mod.rs new file mode 100644 index 0000000..c464d50 --- /dev/null +++ b/rust/src/payments/mod.rs @@ -0,0 +1,82 @@ +//! `gddy payments` — payment method management. + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, RuntimeCommandSpec, + RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +pub fn module() -> Module { + Module::new("Payments", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new("payments", "Manage payment methods")) + .with_command(add_command()) + }) +} + +fn add_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("add", "Add a payment method to your GoDaddy account (opens browser)") + .with_long( + "Opens your default browser to the GoDaddy payment methods management page.\n\ + Note: only credit card or Good-as-Gold can be used for domain purchases.", + ) + .with_system("payments") + .with_tier(Tier::Mutate) + .no_auth(true), + |ctx| async move { + let url = account_url_for_env(&ctx.middleware.env); + open::that(&url) + .map_err(|e| CliCoreError::message(format!("failed to open browser: {e}")))?; + Ok(CommandResult::new(json!({ + "info": "Browser opened to the payment methods management page. \ + Only credit card or Good-as-Gold can be used for domain purchases." + }))) + }, + ) +} + +fn account_url_for_env(env: &str) -> String { + let host = if env == "prod" { + "account.godaddy.com".to_owned() + } else { + format!("account.{env}-godaddy.com") + }; + format!("https://{host}/payment-methods/add-payment?plid=1") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prod_uses_bare_godaddy_domain() { + assert_eq!( + account_url_for_env("prod"), + "https://account.godaddy.com/payment-methods/add-payment?plid=1" + ); + } + + #[test] + fn ote_inserts_env_prefix() { + assert_eq!( + account_url_for_env("ote"), + "https://account.ote-godaddy.com/payment-methods/add-payment?plid=1" + ); + } + + #[test] + fn dev_inserts_env_prefix() { + assert_eq!( + account_url_for_env("dev"), + "https://account.dev-godaddy.com/payment-methods/add-payment?plid=1" + ); + } + + #[test] + fn test_inserts_env_prefix() { + assert_eq!( + account_url_for_env("test"), + "https://account.test-godaddy.com/payment-methods/add-payment?plid=1" + ); + } +} From e5966ed70dfe4c04902be83647f4a464ef60d305 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Mon, 15 Jun 2026 22:22:59 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?account=5Furl=20in=20environments=20module,=20URL=20on=20browse?= =?UTF-8?q?r=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move account URL derivation into environments::ResolvedEnv as account_url, following the domains_api_url pattern. Overridable via _ACCOUNT_URL env var or local environments.toml config entry. - On browser-open failure, emit the URL to stderr so headless environments can navigate manually (mirrors cli-engine pkce pattern). - Remove hardcoded account_url_for_env from payments module. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- rust/src/environments/mod.rs | 63 ++++++++++++++++++++++++++++++ rust/src/payments/mod.rs | 75 +++++++++++++++++------------------- 2 files changed, 99 insertions(+), 39 deletions(-) diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index c9afdcd..c931545 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -74,6 +74,10 @@ pub struct ResolvedEnv { /// availability) live behind a different host than the OAuth/`api_url` /// service; this defaults to `api_url` when not overridden. pub domains_api_url: String, + /// Base URL for the account management site (e.g. adding payment methods). + /// Defaults to `account.godaddy.com` for prod and `account.{env}-godaddy.com` + /// for other environments; overridable via `_ACCOUNT_URL` or local config. + pub account_url: String, /// Optional sso-key for the domain endpoints (which accept either sso-key or /// OAuth). When both are set, `domain:*` commands authenticate with /// `Authorization: sso-key :`; when absent they use the OAuth @@ -115,6 +119,7 @@ impl std::fmt::Debug for ResolvedEnv { .field("auth_url", &self.auth_url) .field("token_url", &self.token_url) .field("domains_api_url", &self.domains_api_url) + .field("account_url", &self.account_url) .field("api_key", &Redacted(&self.api_key)) .field("api_secret", &Redacted(&self.api_secret)) .finish() @@ -150,6 +155,9 @@ pub struct EnvEntry { /// Override base URL for domain commands (defaults to `api_url`). #[serde(default)] pub domains_api_url: Option, + /// Override base URL for the account management site (see [`ResolvedEnv::account_url`]). + #[serde(default)] + pub account_url: Option, /// sso-key credentials for domain endpoints (see [`ResolvedEnv::api_key`]). #[serde(default)] pub api_key: Option, @@ -167,6 +175,7 @@ impl std::fmt::Debug for EnvEntry { .field("auth_url", &self.auth_url) .field("token_url", &self.token_url) .field("domains_api_url", &self.domains_api_url) + .field("account_url", &self.account_url) .field("api_key", &Redacted(&self.api_key)) .field("api_secret", &Redacted(&self.api_secret)) .finish() @@ -195,6 +204,15 @@ pub fn env_prefix(name: &str) -> String { name.to_uppercase().replace('-', "_") } +fn derive_account_url(env_name: &str) -> String { + let host = if env_name == "prod" { + "account.godaddy.com".to_owned() + } else { + format!("account.{env_name}-godaddy.com") + }; + format!("https://{host}") +} + fn derive_auth_url(api_url: &str) -> String { format!("{}/v2/oauth2/authorize", api_url.trim_end_matches('/')) } @@ -298,6 +316,7 @@ fn resolve_with( let mut auth_url: Option = None; let mut token_url: Option = None; let mut domains_api_url: Option = None; + let mut account_url: Option = None; // Layer 2: local config entry (overrides/defines). An empty/whitespace // api_url is ignored so it can't clobber a built-in default. @@ -313,6 +332,7 @@ fn resolve_with( auth_url = entry.auth_url.as_deref().and_then(clean_url); token_url = entry.token_url.as_deref().and_then(clean_url); domains_api_url = entry.domains_api_url.as_deref().and_then(clean_url); + account_url = entry.account_url.as_deref().and_then(clean_url); } // Layer 3: per-env `_*` overrides (highest precedence). Empty values @@ -324,6 +344,9 @@ fn resolve_with( if let Some(url) = var(&format!("{prefix}_DOMAINS_API_URL")).and_then(|v| clean_url(&v)) { domains_api_url = Some(url); } + if let Some(url) = var(&format!("{prefix}_ACCOUNT_URL")).and_then(|v| clean_url(&v)) { + account_url = Some(url); + } // The sso-key is a (key, secret) pair; resolve it atomically from a single // layer — the env-var pair wins over the file pair — so we never mix layers @@ -359,6 +382,7 @@ fn resolve_with( let token_url = token_url.unwrap_or_else(|| derive_token_url(&api_url)); // Domain endpoints default to the same host as the OAuth/api_url service. let domains_api_url = domains_api_url.unwrap_or_else(|| api_url.clone()); + let account_url = account_url.unwrap_or_else(|| derive_account_url(name)); Ok(ResolvedEnv { name: name.to_owned(), @@ -367,6 +391,7 @@ fn resolve_with( auth_url, token_url, domains_api_url, + account_url, api_key, api_secret, }) @@ -481,6 +506,7 @@ mod tests { auth_url: None, token_url: None, domains_api_url: None, + account_url: None, api_key: None, api_secret: None, } @@ -558,6 +584,7 @@ mod tests { auth_url: Some("https://auth.example.invalid/authorize".to_owned()), token_url: Some("https://auth.example.invalid/token".to_owned()), domains_api_url: None, + account_url: None, api_key: None, api_secret: None, }, @@ -623,6 +650,7 @@ mod tests { auth_url: Some("auth.example.invalid/authorize".to_owned()), // no scheme token_url: Some(" ".to_owned()), // blank domains_api_url: None, + account_url: None, api_key: None, api_secret: None, }, @@ -721,6 +749,7 @@ mod tests { auth_url: None, token_url: None, domains_api_url: Some("https://domains.dev.example.invalid".to_owned()), + account_url: None, api_key: Some("KEY".to_owned()), api_secret: Some("SECRET".to_owned()), }, @@ -744,6 +773,7 @@ mod tests { auth_url: None, token_url: None, domains_api_url: Some("https://from-file.example.invalid".to_owned()), + account_url: None, api_key: Some("file-key".to_owned()), api_secret: None, }, @@ -805,4 +835,37 @@ mod tests { // a default (empty) file resolves built-ins correctly via the public API. assert!(is_known("prod")); } + + #[test] + fn account_url_defaults_to_bare_domain_for_prod() { + let file = EnvironmentsFile::default(); + let env = resolve_with("prod", &file, no_vars).expect("prod resolves"); + assert_eq!(env.account_url, "https://account.godaddy.com"); + } + + #[test] + fn account_url_defaults_to_prefixed_domain_for_non_prod() { + let file = EnvironmentsFile::default(); + let env = resolve_with("ote", &file, no_vars).expect("ote resolves"); + assert_eq!(env.account_url, "https://account.ote-godaddy.com"); + } + + #[test] + fn account_url_env_var_overrides_default() { + let file = EnvironmentsFile::default(); + let var = + |k: &str| (k == "PROD_ACCOUNT_URL").then(|| "https://account.override.test".to_owned()); + let env = resolve_with("prod", &file, var).expect("prod resolves"); + assert_eq!(env.account_url, "https://account.override.test"); + } + + #[test] + fn account_url_local_config_overrides_default() { + let mut file = EnvironmentsFile::default(); + let mut e = entry("https://dev.example.invalid"); + e.account_url = Some("https://account.dev.example.invalid".to_owned()); + file.environments.insert("dev".to_owned(), e); + let env = resolve_with("dev", &file, no_vars).expect("dev resolves"); + assert_eq!(env.account_url, "https://account.dev.example.invalid"); + } } diff --git a/rust/src/payments/mod.rs b/rust/src/payments/mod.rs index c464d50..80d1ec7 100644 --- a/rust/src/payments/mod.rs +++ b/rust/src/payments/mod.rs @@ -1,11 +1,19 @@ //! `gddy payments` — payment method management. +use std::io::Write as _; + use cli_engine::{ CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; use serde_json::json; +use crate::environments; + +fn map_env_err(e: environments::EnvError) -> CliCoreError { + CliCoreError::message(e.to_string()) +} + pub fn module() -> Module { Module::new("Payments", |_ctx| { RuntimeGroupSpec::new(GroupSpec::new("payments", "Manage payment methods")) @@ -15,18 +23,30 @@ pub fn module() -> Module { fn add_command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_with_context( - CommandSpec::new("add", "Add a payment method to your GoDaddy account (opens browser)") - .with_long( - "Opens your default browser to the GoDaddy payment methods management page.\n\ + CommandSpec::new( + "add", + "Add a payment method to your GoDaddy account (opens browser)", + ) + .with_long( + "Opens your default browser to the GoDaddy payment methods management page.\n\ Note: only credit card or Good-as-Gold can be used for domain purchases.", - ) - .with_system("payments") - .with_tier(Tier::Mutate) - .no_auth(true), + ) + .with_system("payments") + .with_tier(Tier::Mutate) + .no_auth(true), |ctx| async move { - let url = account_url_for_env(&ctx.middleware.env); - open::that(&url) - .map_err(|e| CliCoreError::message(format!("failed to open browser: {e}")))?; + let env = environments::resolve(&ctx.middleware.env).map_err(map_env_err)?; + let url = format!("{}/payment-methods/add-payment?plid=1", env.account_url); + if let Err(e) = open::that(&url) { + let mut stderr = std::io::stderr().lock(); + drop(writeln!( + stderr, + "Failed to open browser. Visit manually:\n {url}" + )); + return Err(CliCoreError::message(format!( + "failed to open browser: {e}" + ))); + } Ok(CommandResult::new(json!({ "info": "Browser opened to the payment methods management page. \ Only credit card or Good-as-Gold can be used for domain purchases." @@ -35,48 +55,25 @@ fn add_command() -> RuntimeCommandSpec { ) } -fn account_url_for_env(env: &str) -> String { - let host = if env == "prod" { - "account.godaddy.com".to_owned() - } else { - format!("account.{env}-godaddy.com") - }; - format!("https://{host}/payment-methods/add-payment?plid=1") -} - #[cfg(test)] mod tests { use super::*; #[test] - fn prod_uses_bare_godaddy_domain() { + fn prod_account_url_via_environments_module() { + let env = environments::resolve("prod").expect("prod resolves"); assert_eq!( - account_url_for_env("prod"), + format!("{}/payment-methods/add-payment?plid=1", env.account_url), "https://account.godaddy.com/payment-methods/add-payment?plid=1" ); } #[test] - fn ote_inserts_env_prefix() { + fn ote_account_url_via_environments_module() { + let env = environments::resolve("ote").expect("ote resolves"); assert_eq!( - account_url_for_env("ote"), + format!("{}/payment-methods/add-payment?plid=1", env.account_url), "https://account.ote-godaddy.com/payment-methods/add-payment?plid=1" ); } - - #[test] - fn dev_inserts_env_prefix() { - assert_eq!( - account_url_for_env("dev"), - "https://account.dev-godaddy.com/payment-methods/add-payment?plid=1" - ); - } - - #[test] - fn test_inserts_env_prefix() { - assert_eq!( - account_url_for_env("test"), - "https://account.test-godaddy.com/payment-methods/add-payment?plid=1" - ); - } }