diff --git a/rust/src/application/commands/mod.rs b/rust/src/application/commands/mod.rs index dbfacb1..5fa306e 100644 --- a/rust/src/application/commands/mod.rs +++ b/rust/src/application/commands/mod.rs @@ -27,6 +27,7 @@ output_schema!(ApplicationInit { "name": "string"; "status": "string"; "clientId": "string"; + "orgId": "string"; "url": "string"; "proxyUrl": "string"; "authorizationScopes": "[]string"; @@ -390,10 +391,24 @@ fn init_command() -> RuntimeCommandSpec { .short('c') .value_name("PATH") .help("Read defaults from this config file"), + ) + .with_arg( + clap::Arg::new("accept-agreements") + .long("accept-agreements") + .action(clap::ArgAction::SetTrue) + .help( + "Accept GoDaddy Developer agreements non-interactively when \ + onboarding is still pending (required for non-TTY)", + ), ), |ctx| async move { let env = ctx.middleware.env.clone(); let config_path = crate::config::config_path(Some(&env)); + let accept_agreements = ctx + .args + .get("accept-agreements") + .and_then(|value| value.as_bool()) + .unwrap_or(false); // Seed defaults only from an explicit --config; a bad/missing --config is fatal. let existing = match ctx.args.get("config").and_then(|v| v.as_str()) { @@ -459,6 +474,14 @@ fn init_command() -> RuntimeCommandSpec { } } + let credential = ctx.credential().await?; + let onboarding = crate::onboarding::ensure_ready_for_app_init( + &credential.token, + &env, + accept_agreements, + ) + .await?; + let client = make_client(&ctx).await?; let data = client .create_application(json!({ @@ -467,6 +490,7 @@ fn init_command() -> RuntimeCommandSpec { "description": description, "url": url, "proxyUrl": proxy_url, + "organizationId": &onboarding.org_id, "authorizationScopes": scopes, })) .await @@ -537,6 +561,7 @@ fn init_command() -> RuntimeCommandSpec { "name": name, "status": app["status"].as_str().unwrap_or("").to_owned(), "clientId": client_id, + "orgId": onboarding.org_id, "url": url, "proxyUrl": proxy_url, "authorizationScopes": scopes, diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index 55ba66a..03dc39b 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -69,6 +69,7 @@ struct Builtin { name: &'static str, api_url: &'static str, client_id: &'static str, + devx_core_url: &'static str, } /// Public-safe, compiled-in environments. `api.ote-godaddy.com` is public, and @@ -78,11 +79,13 @@ const BUILTINS: &[Builtin] = &[ name: "ote", api_url: "https://api.ote-godaddy.com", client_id: "91660d79-c909-426c-b5c8-e0f575e8fcd2", + devx_core_url: "https://api.developer.commerce.ote-godaddy.com", }, Builtin { name: "prod", api_url: "https://api.godaddy.com", client_id: "bc87f347-af82-4892-833f-818f54a0e79e", + devx_core_url: "https://api.developer.commerce.godaddy.com", }, ]; @@ -160,6 +163,28 @@ fn clean_url(raw: &str) -> Option { (!host.is_empty()).then(|| trimmed.to_owned()) } +fn devx_core_url_with(name: &str, var: impl Fn(&str) -> Option) -> Option { + let prefix = env_prefix(name); + var(&format!("{prefix}_DEVX_CORE_URL")) + .and_then(|value| clean_url(&value)) + .or_else(|| var("DEVX_CORE_URL").and_then(|value| clean_url(&value))) + .or_else(|| { + BUILTINS + .iter() + .find(|env| env.name == name) + .map(|env| env.devx_core_url.to_owned()) + }) +} + +/// Base URL for the DevX Core API gateway for the given environment. +/// +/// Custom environments must set `_DEVX_CORE_URL` (for example, +/// `DEV_DEVX_CORE_URL`) or the global `DEVX_CORE_URL`. `prod` and `ote` use +/// their compiled-in endpoints unless either variable overrides them. +pub fn devx_core_url(name: &str) -> Option { + devx_core_url_with(name, |key| std::env::var(key).ok()) +} + /// Scans the raw process argv for a `--env ` / `--env=` token. /// /// cli-engine's built-in global `--env` flag (and any command-local `--env` @@ -768,4 +793,45 @@ mod tests { Some("-foo".to_owned()) ); } + + #[test] + fn devx_core_url_uses_prod_and_ote_builtins() { + assert_eq!( + devx_core_url_with("prod", |_| None).as_deref(), + Some("https://api.developer.commerce.godaddy.com") + ); + assert_eq!( + devx_core_url_with("ote", |_| None).as_deref(), + Some("https://api.developer.commerce.ote-godaddy.com") + ); + } + + #[test] + fn devx_core_url_global_override_wins() { + assert_eq!( + devx_core_url_with("prod", |key| { + (key == "DEVX_CORE_URL").then(|| " http://localhost:4000/ ".to_owned()) + }) + .as_deref(), + Some("http://localhost:4000") + ); + } + + #[test] + fn devx_core_url_per_environment_override_wins_over_global() { + assert_eq!( + devx_core_url_with("dev", |key| match key { + "DEV_DEVX_CORE_URL" => Some("https://dev-core.example.test/".to_owned()), + "DEVX_CORE_URL" => Some("https://shared-core.example.test".to_owned()), + _ => None, + }) + .as_deref(), + Some("https://dev-core.example.test") + ); + } + + #[test] + fn devx_core_url_custom_env_requires_override() { + assert_eq!(devx_core_url_with("dev", |_| None), None); + } } diff --git a/rust/src/main.rs b/rust/src/main.rs index b35b723..82f6e7a 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -11,6 +11,7 @@ mod environments; mod extension; mod hosting; mod next_action; +pub mod onboarding; mod output_schema; mod pat; mod payment_methods; diff --git a/rust/src/onboarding/client.rs b/rust/src/onboarding/client.rs new file mode 100644 index 0000000..2462257 --- /dev/null +++ b/rust/src/onboarding/client.rs @@ -0,0 +1,186 @@ +use serde::de::DeserializeOwned; + +use super::types::{ApiEnvelope, CliData, CliOnboardingResult, OnboardingStatus, StatusData}; + +#[derive(Debug, thiserror::Error)] +pub enum OnboardingError { + #[error("onboarding request could not be sent")] + Request(#[source] reqwest::Error), + #[error("onboarding service returned HTTP {0}")] + Http(reqwest::StatusCode), + #[error("onboarding service returned an invalid response")] + Decode(#[source] reqwest::Error), + #[error("onboarding service rejected the request")] + Unsuccessful, +} + +pub struct OnboardingClient { + base_url: String, + http: reqwest::Client, +} + +impl OnboardingClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + http: reqwest::Client::new(), + } + } + + pub async fn status(&self, token: &str) -> Result { + let envelope: ApiEnvelope = + self.post("/api/v1/onboarding/status", token).await?; + if !envelope.success { + return Err(OnboardingError::Unsuccessful); + } + + Ok(OnboardingStatus { + org_id: envelope.data.id, + status: envelope.data.status, + }) + } + + pub async fn complete(&self, token: &str) -> Result { + let envelope: ApiEnvelope = self.post("/api/v1/onboarding/cli", token).await?; + if !envelope.success { + return Err(OnboardingError::Unsuccessful); + } + + Ok(CliOnboardingResult { + organization_id: envelope.data.organization_id, + status: envelope.data.status, + }) + } + + async fn post( + &self, + path: &str, + token: &str, + ) -> Result { + let response = self + .http + .post(format!("{}{path}", self.base_url)) + .bearer_auth(token) + .json(&serde_json::json!({})) + .send() + .await + .map_err(OnboardingError::Request)?; + if !response.status().is_success() { + return Err(OnboardingError::Http(response.status())); + } + + response.json().await.map_err(OnboardingError::Decode) + } +} + +#[cfg(test)] +mod tests { + use httpmock::{Method::POST, MockServer}; + use serde_json::json; + + use super::OnboardingClient; + use crate::onboarding::{CliOnboardingResult, OnboardingStatus}; + + #[tokio::test] + async fn status_posts_bearer_and_parses_response() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/api/v1/onboarding/status") + .header("authorization", "Bearer test-token") + .json_body(json!({})); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-1", "status": "PENDING" } + })); + }) + .await; + + let result = OnboardingClient::new(server.base_url()) + .status("test-token") + .await + .expect("status response"); + + assert_eq!( + result, + OnboardingStatus { + org_id: "org-1".to_owned(), + status: "PENDING".to_owned(), + } + ); + mock.assert_async().await; + } + + #[tokio::test] + async fn complete_posts_bearer_and_parses_response() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/api/v1/onboarding/cli") + .header("authorization", "Bearer test-token") + .json_body(json!({})); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-1", "status": "ACTIVE" } + })); + }) + .await; + + let result = OnboardingClient::new(server.base_url()) + .complete("test-token") + .await + .expect("completion response"); + + assert_eq!( + result, + CliOnboardingResult { + organization_id: "org-1".to_owned(), + status: "ACTIVE".to_owned(), + } + ); + mock.assert_async().await; + } + + #[tokio::test] + async fn errors_do_not_include_response_body_or_token() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(500).body("secret backend detail"); + }) + .await; + + let error = OnboardingClient::new(server.base_url()) + .status("test-token") + .await + .expect_err("status must fail"); + let message = error.to_string(); + + assert!(message.contains("500")); + assert!(!message.contains("secret backend detail")); + assert!(!message.contains("test-token")); + } + + #[tokio::test] + async fn success_false_is_rejected() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": false, + "data": { "id": "org-1", "status": "PENDING" } + })); + }) + .await; + + let error = OnboardingClient::new(server.base_url()) + .status("test-token") + .await + .expect_err("unsuccessful envelope"); + assert_eq!(error.to_string(), "onboarding service rejected the request"); + } +} diff --git a/rust/src/onboarding/ensure.rs b/rust/src/onboarding/ensure.rs new file mode 100644 index 0000000..66cca40 --- /dev/null +++ b/rust/src/onboarding/ensure.rs @@ -0,0 +1,381 @@ +use std::borrow::Cow; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io::{self, IsTerminal}; + +#[cfg(test)] +use std::io::{BufRead, Write}; + +use cli_engine::{CliCoreError, DetailedError}; + +use super::client::OnboardingClient; +use super::flow::{AgreementDecision, decide}; +use super::prompt::prompt_accept_agreements; +use crate::environments; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EnsureOutcome { + pub org_id: String, +} + +#[derive(Debug)] +pub(crate) struct AgreementsRequiredError; + +impl Display for AgreementsRequiredError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str( + "GoDaddy Developer agreements must be accepted before creating an application. \ + Re-run interactively, or pass --accept-agreements.", + ) + } +} + +impl Error for AgreementsRequiredError {} + +impl DetailedError for AgreementsRequiredError { + fn error_code(&self) -> Cow<'static, str> { + Cow::Borrowed("AGREEMENTS_REQUIRED") + } + + fn error_system(&self) -> Option> { + Some(Cow::Borrowed("applications")) + } + + fn error_request_id(&self) -> Option> { + None + } +} + +/// Ensure the authenticated customer has an active org before `application init`. +/// +/// Prompts only for `PENDING` users. Does not run during `auth login`. +pub async fn ensure_ready_for_app_init( + token: &str, + env: &str, + accept_agreements: bool, +) -> Result { + let Some(base_url) = environments::devx_core_url(env) else { + return Err(CliCoreError::message(format!( + "DevX core URL is not configured for environment `{env}`. Set {}_DEVX_CORE_URL or DEVX_CORE_URL to continue.", + environments::env_prefix(env), + ))); + }; + + let client = OnboardingClient::new(base_url); + let status = client.status(token).await.map_err(|error| { + CliCoreError::message(format!( + "Could not check developer onboarding status before creating an application: {error}" + )) + })?; + + let is_tty = io::stdin().is_terminal(); + let prompt_accepted = if status.status == "PENDING" && is_tty { + // Keep the stdin lock off the async path so the command future stays `Send`. + let prompt_result = { + let mut stdin = io::stdin().lock(); + let mut stderr = io::stderr(); + prompt_accept_agreements(&mut stdin, &mut stderr) + }; + Some(prompt_result.map_err(prompt_error)?) + } else { + None + }; + + finish_decision( + client, + token, + &status, + is_tty, + accept_agreements, + prompt_accepted, + ) + .await +} + +#[cfg(test)] +pub(crate) async fn ensure_ready_for_app_init_with( + token: &str, + base_url: &str, + accept_agreements: bool, + is_tty: bool, + reader: &mut impl BufRead, + stderr: &mut impl Write, +) -> Result { + let client = OnboardingClient::new(base_url); + let status = client.status(token).await.map_err(|error| { + CliCoreError::message(format!( + "Could not check developer onboarding status before creating an application: {error}" + )) + })?; + + let prompt_accepted = if status.status == "PENDING" && is_tty { + Some(prompt_accept_agreements(reader, stderr).map_err(prompt_error)?) + } else { + None + }; + + finish_decision( + client, + token, + &status, + is_tty, + accept_agreements, + prompt_accepted, + ) + .await +} + +fn prompt_error(error: io::Error) -> CliCoreError { + CliCoreError::message(format!( + "Could not collect developer agreement acceptance: {error}" + )) +} + +async fn finish_decision( + client: OnboardingClient, + token: &str, + status: &super::types::OnboardingStatus, + is_tty: bool, + accept_agreements: bool, + prompt_accepted: Option, +) -> Result { + match decide(status, is_tty, accept_agreements, prompt_accepted) { + AgreementDecision::AlreadyComplete { org_id } => Ok(EnsureOutcome { org_id }), + AgreementDecision::CompletePending => { + let result = client.complete(token).await.map_err(|error| { + CliCoreError::message(format!( + "Could not complete developer onboarding before creating an application: {error}" + )) + })?; + Ok(EnsureOutcome { + org_id: result.organization_id, + }) + } + AgreementDecision::AgreementsRequired => { + Err(CliCoreError::with_detailed_error(AgreementsRequiredError)) + } + AgreementDecision::UnsupportedStatus => Err(CliCoreError::message(format!( + "Developer organization status `{}` cannot create applications yet.", + status.status + ))), + } +} + +#[cfg(test)] +mod tests { + use std::io::{self, Cursor, Write}; + + use httpmock::{Method::POST, MockServer}; + use serde_json::json; + + use super::{EnsureOutcome, ensure_ready_for_app_init_with}; + + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::Error::other("writer unavailable")) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::other("writer unavailable")) + } + } + + #[tokio::test] + async fn active_status_is_ready_without_prompt_or_cli() { + let server = MockServer::start_async().await; + let status = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-active", "status": "ACTIVE" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-active", "status": "ACTIVE" } + })); + }) + .await; + + let mut input = Cursor::new(b""); + let mut stderr = Vec::new(); + let outcome = ensure_ready_for_app_init_with( + "test-token", + &server.base_url(), + false, + true, + &mut input, + &mut stderr, + ) + .await + .expect("active ready"); + + assert_eq!( + outcome, + EnsureOutcome { + org_id: "org-active".to_owned() + } + ); + assert!(stderr.is_empty()); + status.assert_async().await; + assert_eq!(complete.hits_async().await, 0); + } + + #[tokio::test] + async fn pending_non_tty_without_flag_requires_agreements() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-pending", "status": "ACTIVE" } + })); + }) + .await; + + let err = ensure_ready_for_app_init_with( + "test-token", + &server.base_url(), + false, + false, + &mut Cursor::new(b""), + &mut Vec::new(), + ) + .await + .expect_err("agreements required"); + + assert!(err.to_string().contains("agreements must be accepted")); + assert_eq!(complete.hits_async().await, 0); + } + + #[tokio::test] + async fn pending_non_tty_with_flag_completes() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-pending", "status": "ACTIVE" } + })); + }) + .await; + + let outcome = ensure_ready_for_app_init_with( + "test-token", + &server.base_url(), + true, + false, + &mut Cursor::new(b""), + &mut Vec::new(), + ) + .await + .expect("completed"); + + assert_eq!(outcome.org_id, "org-pending"); + complete.assert_async().await; + } + + #[tokio::test] + async fn pending_tty_enter_completes_with_prompt() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-pending", "status": "ACTIVE" } + })); + }) + .await; + + let mut stderr = Vec::new(); + let outcome = ensure_ready_for_app_init_with( + "test-token", + &server.base_url(), + false, + true, + &mut Cursor::new(b"\n"), + &mut stderr, + ) + .await + .expect("completed"); + + assert_eq!(outcome.org_id, "org-pending"); + let prompt = String::from_utf8(stderr).expect("utf8"); + assert!(prompt.contains("terms-of-use")); + complete.assert_async().await; + } + + #[tokio::test] + async fn pending_tty_reports_prompt_io_errors() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-pending", "status": "ACTIVE" } + })); + }) + .await; + + let err = ensure_ready_for_app_init_with( + "test-token", + &server.base_url(), + false, + true, + &mut Cursor::new(b"\n"), + &mut FailingWriter, + ) + .await + .expect_err("prompt I/O error must be reported"); + + assert!(err.to_string().contains("writer unavailable")); + assert_eq!(complete.hits_async().await, 0); + } +} diff --git a/rust/src/onboarding/flow.rs b/rust/src/onboarding/flow.rs new file mode 100644 index 0000000..f4eb0db --- /dev/null +++ b/rust/src/onboarding/flow.rs @@ -0,0 +1,85 @@ +use super::types::OnboardingStatus; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AgreementDecision { + AlreadyComplete { org_id: String }, + CompletePending, + AgreementsRequired, + UnsupportedStatus, +} + +pub fn decide( + status: &OnboardingStatus, + is_tty: bool, + accept_flag: bool, + prompt_accepted: Option, +) -> AgreementDecision { + match status.status.as_str() { + "ACTIVE" => AgreementDecision::AlreadyComplete { + org_id: status.org_id.clone(), + }, + "PENDING" if is_tty && prompt_accepted == Some(true) => AgreementDecision::CompletePending, + "PENDING" if !is_tty && accept_flag => AgreementDecision::CompletePending, + "PENDING" => AgreementDecision::AgreementsRequired, + _ => AgreementDecision::UnsupportedStatus, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onboarding::OnboardingStatus; + + fn status(value: &str) -> OnboardingStatus { + OnboardingStatus { + org_id: "org-1".to_owned(), + status: value.to_owned(), + } + } + + #[test] + fn active_user_never_needs_acceptance() { + assert_eq!( + decide(&status("ACTIVE"), true, false, Some(false)), + AgreementDecision::AlreadyComplete { + org_id: "org-1".to_owned(), + } + ); + } + + #[test] + fn pending_non_tty_requires_flag() { + assert_eq!( + decide(&status("PENDING"), false, false, None), + AgreementDecision::AgreementsRequired + ); + } + + #[test] + fn pending_non_tty_flag_completes() { + assert_eq!( + decide(&status("PENDING"), false, true, None), + AgreementDecision::CompletePending + ); + } + + #[test] + fn pending_tty_requires_successful_prompt() { + assert_eq!( + decide(&status("PENDING"), true, true, Some(true)), + AgreementDecision::CompletePending + ); + assert_eq!( + decide(&status("PENDING"), true, true, Some(false)), + AgreementDecision::AgreementsRequired + ); + } + + #[test] + fn unknown_status_is_not_accepted_or_prompted() { + assert_eq!( + decide(&status("SUSPENDED"), true, true, Some(true)), + AgreementDecision::UnsupportedStatus + ); + } +} diff --git a/rust/src/onboarding/mod.rs b/rust/src/onboarding/mod.rs new file mode 100644 index 0000000..9d7c76e --- /dev/null +++ b/rust/src/onboarding/mod.rs @@ -0,0 +1,11 @@ +mod client; +mod ensure; +mod flow; +mod prompt; +mod types; + +pub use client::{OnboardingClient, OnboardingError}; +pub use ensure::{EnsureOutcome, ensure_ready_for_app_init}; +pub use flow::{AgreementDecision, decide}; +pub use prompt::prompt_accept_agreements; +pub use types::{CliOnboardingResult, OnboardingStatus}; diff --git a/rust/src/onboarding/prompt.rs b/rust/src/onboarding/prompt.rs new file mode 100644 index 0000000..e82870e --- /dev/null +++ b/rust/src/onboarding/prompt.rs @@ -0,0 +1,63 @@ +use std::io::{self, BufRead, Write}; + +const TERMS_OF_USE: &str = "https://developer.commerce.godaddy.com/legal/agreements/terms-of-use"; +const PRIVACY_POLICY: &str = + "https://developer.commerce.godaddy.com/legal/agreements/privacy-policy"; +const DEVELOPER_AGREEMENT: &str = + "https://developer.commerce.godaddy.com/legal/agreements/developer-agreement"; + +pub fn prompt_accept_agreements( + reader: &mut R, + writer: &mut W, +) -> io::Result { + writeln!(writer)?; + writeln!( + writer, + "By continuing, you agree to the GoDaddy Developer terms:" + )?; + writeln!(writer)?; + writeln!(writer, " Terms of Use: {TERMS_OF_USE}")?; + writeln!(writer, " Privacy Policy: {PRIVACY_POLICY}")?; + writeln!(writer, " Developer Agreement: {DEVELOPER_AGREEMENT}")?; + writeln!(writer)?; + write!(writer, "Press Enter to accept and continue...")?; + writer.flush()?; + + let mut line = String::new(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + return Ok(false); + } + + Ok(line.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::prompt_accept_agreements; + + #[test] + fn prompt_writes_all_links_and_accepts_enter() { + let mut input = Cursor::new(b"\n"); + let mut output = Vec::new(); + assert!(prompt_accept_agreements(&mut input, &mut output).expect("prompt")); + let text = String::from_utf8(output).expect("utf8"); + assert!(text.contains("terms-of-use")); + assert!(text.contains("privacy-policy")); + assert!(text.contains("developer-agreement")); + } + + #[test] + fn prompt_fails_closed_on_eof() { + let mut input = Cursor::new([]); + assert!(!prompt_accept_agreements(&mut input, &mut Vec::new()).expect("prompt")); + } + + #[test] + fn prompt_rejects_non_empty_response() { + let mut input = Cursor::new(b"no\n"); + assert!(!prompt_accept_agreements(&mut input, &mut Vec::new()).expect("prompt")); + } +} diff --git a/rust/src/onboarding/types.rs b/rust/src/onboarding/types.rs new file mode 100644 index 0000000..9f5b12a --- /dev/null +++ b/rust/src/onboarding/types.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OnboardingStatus { + pub org_id: String, + pub status: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CliOnboardingResult { + pub organization_id: String, + pub status: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ApiEnvelope { + pub success: bool, + pub data: T, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StatusData { + pub id: String, + pub status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliData { + pub organization_id: String, + pub status: String, +}