Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ Returns environment/auth snapshots and the full command tree.
- `gddy application add extension checkout --name <name> --handle <handle> --source <source> --target <targets>`
- `gddy application add extension blocks --source <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`
Expand Down
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
63 changes: 63 additions & 0 deletions rust/src/environments/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<PREFIX>_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 <key>:<secret>`; when absent they use the OAuth
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -150,6 +155,9 @@ pub struct EnvEntry {
/// Override base URL for domain commands (defaults to `api_url`).
#[serde(default)]
pub domains_api_url: Option<String>,
/// Override base URL for the account management site (see [`ResolvedEnv::account_url`]).
#[serde(default)]
pub account_url: Option<String>,
/// sso-key credentials for domain endpoints (see [`ResolvedEnv::api_key`]).
#[serde(default)]
pub api_key: Option<String>,
Expand All @@ -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()
Expand Down Expand Up @@ -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('/'))
}
Expand Down Expand Up @@ -298,6 +316,7 @@ fn resolve_with(
let mut auth_url: Option<String> = None;
let mut token_url: Option<String> = None;
let mut domains_api_url: Option<String> = None;
let mut account_url: Option<String> = None;

// Layer 2: local config entry (overrides/defines). An empty/whitespace
// api_url is ignored so it can't clobber a built-in default.
Expand All @@ -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 `<PREFIX>_*` overrides (highest precedence). Empty values
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -367,6 +391,7 @@ fn resolve_with(
auth_url,
token_url,
domains_api_url,
account_url,
api_key,
api_secret,
})
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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()),
},
Expand All @@ -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,
},
Expand Down Expand Up @@ -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");
}
}
2 changes: 2 additions & 0 deletions rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod domain;
mod env;
mod environments;
mod extension;
mod payments;
mod webhook;

use std::{process::ExitCode, sync::Arc};
Expand Down Expand Up @@ -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()),
);

Expand Down
79 changes: 79 additions & 0 deletions rust/src/payments/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! `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"))
.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 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."
})))
},
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn prod_account_url_via_environments_module() {
let env = environments::resolve("prod").expect("prod resolves");
assert_eq!(
format!("{}/payment-methods/add-payment?plid=1", env.account_url),
"https://account.godaddy.com/payment-methods/add-payment?plid=1"
);
}

#[test]
fn ote_account_url_via_environments_module() {
let env = environments::resolve("ote").expect("ote resolves");
assert_eq!(
format!("{}/payment-methods/add-payment?plid=1", env.account_url),
"https://account.ote-godaddy.com/payment-methods/add-payment?plid=1"
);
}
}
Loading