1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3
4#[derive(Serialize)]
5struct DomainAddInput<'a> {
6 project_id: &'a str,
7 domain: &'a str,
8}
9
10#[derive(Deserialize)]
11#[serde(tag = "t", rename_all_fields = "camelCase")]
12enum DomainAdd {
13 Ok,
14 NotLoggedIn,
15 NotFound,
16 InvalidDomain { message: String },
17 DomainTaken { existing_project_id: String },
18 AlreadyHasDomain { current_domain: String },
19 InternalError,
20}
21
22pub async fn domain_add(project_id: &str, domain: &str) -> Result<()> {
23 let creds = crate::credentials::require()?;
24 let client = reqwest::Client::new();
25 let url = format!(
26 "{}/__forte_action/domain_add",
27 creds.control_url.trim_end_matches('/')
28 );
29 let resp = client
30 .post(&url)
31 .bearer_auth(&creds.token)
32 .json(&DomainAddInput { project_id, domain })
33 .send()
34 .await?
35 .error_for_status()?;
36 let raw: DomainAdd = resp.json().await?;
37 match raw {
38 DomainAdd::Ok => {
39 println!("domain '{domain}' attached to project '{project_id}'");
40 println!(
41 "Cloudflare hostname registration is queued; run `fn0 domain status` to check."
42 );
43 Ok(())
44 }
45 DomainAdd::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
46 DomainAdd::NotFound => Err(anyhow!(
47 "project '{project_id}' not found or not owned by you."
48 )),
49 DomainAdd::InvalidDomain { message } => Err(anyhow!("invalid domain: {message}")),
50 DomainAdd::DomainTaken {
51 existing_project_id,
52 } => Err(anyhow!(
53 "domain '{domain}' already in use by project '{existing_project_id}'"
54 )),
55 DomainAdd::AlreadyHasDomain { current_domain } => Err(anyhow!(
56 "project '{project_id}' already has domain '{current_domain}'; remove it first"
57 )),
58 DomainAdd::InternalError => {
59 Err(anyhow!("domain_add: server error; check fn0-control logs"))
60 }
61 }
62}
63
64#[derive(Serialize)]
65struct DomainProjectInput<'a> {
66 project_id: &'a str,
67}
68
69#[derive(Deserialize)]
70#[serde(tag = "t", rename_all_fields = "camelCase")]
71enum DomainRemove {
72 Ok { removed_domain: String },
73 NotLoggedIn,
74 NotFound,
75 NoDomain,
76 InternalError,
77}
78
79pub async fn domain_remove(project_id: &str) -> Result<()> {
80 let creds = crate::credentials::require()?;
81 let client = reqwest::Client::new();
82 let url = format!(
83 "{}/__forte_action/domain_remove",
84 creds.control_url.trim_end_matches('/')
85 );
86 let resp = client
87 .post(&url)
88 .bearer_auth(&creds.token)
89 .json(&DomainProjectInput { project_id })
90 .send()
91 .await?
92 .error_for_status()?;
93 let raw: DomainRemove = resp.json().await?;
94 match raw {
95 DomainRemove::Ok { removed_domain } => {
96 println!("domain '{removed_domain}' detached from project '{project_id}'");
97 println!("Cloudflare hostname removal is queued.");
98 Ok(())
99 }
100 DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
101 DomainRemove::NotFound => Err(anyhow!(
102 "project '{project_id}' not found or not owned by you."
103 )),
104 DomainRemove::NoDomain => Err(anyhow!(
105 "no custom domain attached to project '{project_id}'."
106 )),
107 DomainRemove::InternalError => Err(anyhow!(
108 "domain_remove: server error; check fn0-control logs"
109 )),
110 }
111}
112
113#[derive(Deserialize)]
114#[serde(tag = "t", rename_all_fields = "camelCase")]
115pub enum DomainStatus {
116 NotConfigured,
117 Configured {
118 domain: String,
119 cloudflare_status: CloudflareStatus,
120 },
121 NotLoggedIn,
122 NotFound,
123 InternalError,
124}
125
126#[derive(Deserialize)]
127#[serde(tag = "t", rename_all_fields = "camelCase")]
128pub enum CloudflareStatus {
129 Active,
130 Pending,
131 Missing,
132 Other { value: String },
133}
134
135pub async fn fetch_domain_status(
136 creds: &crate::credentials::Credentials,
137 project_id: &str,
138) -> Result<DomainStatus> {
139 let client = reqwest::Client::new();
140 let url = format!(
141 "{}/__forte_action/domain_status",
142 creds.control_url.trim_end_matches('/')
143 );
144 let resp = client
145 .post(&url)
146 .bearer_auth(&creds.token)
147 .json(&DomainProjectInput { project_id })
148 .send()
149 .await?
150 .error_for_status()?;
151 Ok(resp.json().await?)
152}
153
154pub async fn domain_status(project_id: &str) -> Result<()> {
155 let creds = crate::credentials::require()?;
156 match fetch_domain_status(&creds, project_id).await? {
157 DomainStatus::NotConfigured => {
158 println!("project '{project_id}' has no custom domain configured.");
159 Ok(())
160 }
161 DomainStatus::Configured {
162 domain,
163 cloudflare_status,
164 } => {
165 println!("project '{project_id}' custom domain: {domain}");
166 println!(
167 "cloudflare status: {}",
168 format_cloudflare_status(&cloudflare_status)
169 );
170 Ok(())
171 }
172 DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
173 DomainStatus::NotFound => Err(anyhow!(
174 "project '{project_id}' not found or not owned by you."
175 )),
176 DomainStatus::InternalError => Err(anyhow!(
177 "domain_status: server error; check fn0-control logs"
178 )),
179 }
180}
181
182pub(crate) fn format_cloudflare_status(status: &CloudflareStatus) -> String {
183 match status {
184 CloudflareStatus::Active => "active".to_string(),
185 CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
186 CloudflareStatus::Missing => {
187 "missing on Cloudflare (registration may still be in progress)".to_string()
188 }
189 CloudflareStatus::Other { value } => format!("other: {value}"),
190 }
191}