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 => Err(anyhow!("domain_add: server error; check fn0-control logs")),
59 }
60}
61
62#[derive(Serialize)]
63struct DomainProjectInput<'a> {
64 project_id: &'a str,
65}
66
67#[derive(Deserialize)]
68#[serde(tag = "t", rename_all_fields = "camelCase")]
69enum DomainRemove {
70 Ok { removed_domain: String },
71 NotLoggedIn,
72 NotFound,
73 NoDomain,
74 InternalError,
75}
76
77pub async fn domain_remove(project_id: &str) -> Result<()> {
78 let creds = crate::credentials::require()?;
79 let client = reqwest::Client::new();
80 let url = format!(
81 "{}/__forte_action/domain_remove",
82 creds.control_url.trim_end_matches('/')
83 );
84 let resp = client
85 .post(&url)
86 .bearer_auth(&creds.token)
87 .json(&DomainProjectInput { project_id })
88 .send()
89 .await?
90 .error_for_status()?;
91 let raw: DomainRemove = resp.json().await?;
92 match raw {
93 DomainRemove::Ok { removed_domain } => {
94 println!("domain '{removed_domain}' detached from project '{project_id}'");
95 println!("Cloudflare hostname removal is queued.");
96 Ok(())
97 }
98 DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
99 DomainRemove::NotFound => Err(anyhow!(
100 "project '{project_id}' not found or not owned by you."
101 )),
102 DomainRemove::NoDomain => Err(anyhow!(
103 "no custom domain attached to project '{project_id}'."
104 )),
105 DomainRemove::InternalError => Err(anyhow!(
106 "domain_remove: server error; check fn0-control logs"
107 )),
108 }
109}
110
111#[derive(Deserialize)]
112#[serde(tag = "t", rename_all_fields = "camelCase")]
113enum DomainStatus {
114 NotConfigured,
115 Configured {
116 domain: String,
117 cloudflare_status: CloudflareStatus,
118 },
119 NotLoggedIn,
120 NotFound,
121 InternalError,
122}
123
124#[derive(Deserialize)]
125#[serde(tag = "t", rename_all_fields = "camelCase")]
126enum CloudflareStatus {
127 Active,
128 Pending,
129 Missing,
130 Other { value: String },
131}
132
133pub async fn domain_status(project_id: &str) -> Result<()> {
134 let creds = crate::credentials::require()?;
135 let client = reqwest::Client::new();
136 let url = format!(
137 "{}/__forte_action/domain_status",
138 creds.control_url.trim_end_matches('/')
139 );
140 let resp = client
141 .post(&url)
142 .bearer_auth(&creds.token)
143 .json(&DomainProjectInput { project_id })
144 .send()
145 .await?
146 .error_for_status()?;
147 let raw: DomainStatus = resp.json().await?;
148 match raw {
149 DomainStatus::NotConfigured => {
150 println!("project '{project_id}' has no custom domain configured.");
151 Ok(())
152 }
153 DomainStatus::Configured {
154 domain,
155 cloudflare_status,
156 } => {
157 println!("project '{project_id}' custom domain: {domain}");
158 println!(
159 "cloudflare status: {}",
160 format_cloudflare_status(&cloudflare_status)
161 );
162 Ok(())
163 }
164 DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
165 DomainStatus::NotFound => Err(anyhow!(
166 "project '{project_id}' not found or not owned by you."
167 )),
168 DomainStatus::InternalError => Err(anyhow!(
169 "domain_status: server error; check fn0-control logs"
170 )),
171 }
172}
173
174fn format_cloudflare_status(status: &CloudflareStatus) -> String {
175 match status {
176 CloudflareStatus::Active => "active".to_string(),
177 CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
178 CloudflareStatus::Missing => {
179 "missing on Cloudflare (registration may still be in progress)".to_string()
180 }
181 CloudflareStatus::Other { value } => format!("other: {value}"),
182 }
183}