Skip to main content

fn0_deploy/
lib.rs

1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5pub mod credentials;
6
7pub const MAX_PROJECT_NAME_LEN: usize = 100;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum NameError {
11    Empty,
12    TooLong { len: usize },
13    InvalidChar { ch: char },
14}
15
16impl std::fmt::Display for NameError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            NameError::Empty => write!(f, "name cannot be empty"),
20            NameError::TooLong { len } => write!(
21                f,
22                "name too long: {len} chars (max {MAX_PROJECT_NAME_LEN})"
23            ),
24            NameError::InvalidChar { ch } => write!(
25                f,
26                "name contains invalid character {ch:?}; allowed: letters, digits, '.', '_', '-'"
27            ),
28        }
29    }
30}
31
32impl std::error::Error for NameError {}
33
34pub fn validate_project_name(s: &str) -> std::result::Result<(), NameError> {
35    let len = s.chars().count();
36    if len == 0 {
37        return Err(NameError::Empty);
38    }
39    if len > MAX_PROJECT_NAME_LEN {
40        return Err(NameError::TooLong { len });
41    }
42    if let Some(ch) = s
43        .chars()
44        .find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '_' && *c != '-')
45    {
46        return Err(NameError::InvalidChar { ch });
47    }
48    Ok(())
49}
50
51pub fn is_valid_project_name(s: &str) -> bool {
52    validate_project_name(s).is_ok()
53}
54
55#[derive(Serialize)]
56struct NewProjectInput<'a> {
57    name: &'a str,
58}
59
60#[derive(Deserialize)]
61#[serde(tag = "t", rename_all_fields = "camelCase")]
62enum NewProject {
63    Ok { project_id: String },
64    NotLoggedIn,
65    InvalidName,
66    InternalError,
67}
68
69pub async fn ensure_project_id(
70    client: &reqwest::Client,
71    control_url: &str,
72    token: &str,
73    project_name: &str,
74    project_id: &mut Option<String>,
75) -> Result<String> {
76    if let Some(id) = project_id.as_ref() {
77        return Ok(id.clone());
78    }
79    let url = format!(
80        "{}/__forte_action/new_project",
81        control_url.trim_end_matches('/')
82    );
83    let resp = client
84        .post(&url)
85        .bearer_auth(token)
86        .json(&NewProjectInput { name: project_name })
87        .send()
88        .await?
89        .error_for_status()
90        .map_err(|e| anyhow!("new_project failed: {e}"))?;
91    let raw: NewProject = resp.json().await?;
92    let id = match raw {
93        NewProject::Ok { project_id } => project_id,
94        NewProject::NotLoggedIn => {
95            return Err(anyhow!("control rejected token; run `fn0 login` again."));
96        }
97        NewProject::InvalidName => {
98            return Err(anyhow!(
99                "control rejected project name '{project_name}': must be 1-{MAX_PROJECT_NAME_LEN} chars of letters, digits, '.', '_', '-'"
100            ));
101        }
102        NewProject::InternalError => {
103            return Err(anyhow!(
104                "new_project: server error; check fn0-control logs"
105            ));
106        }
107    };
108    *project_id = Some(id.clone());
109    Ok(id)
110}
111
112#[derive(Serialize)]
113struct DeployInput<'a> {
114    project_id: &'a str,
115    build_id: &'a str,
116    files: Vec<DeployFile>,
117    jobs: &'a [CronJob],
118    cron_updated_at: &'a str,
119}
120
121#[derive(Serialize, Deserialize, Clone, Debug)]
122pub struct CronJob {
123    pub function: String,
124    pub every_minutes: u32,
125}
126
127#[derive(Serialize)]
128struct DeployFile {
129    path: String,
130    size: u64,
131}
132
133#[derive(Deserialize)]
134#[serde(tag = "t", rename_all_fields = "camelCase")]
135enum Deploy {
136    Ok {
137        presigned_put_url: String,
138        object_key: String,
139        static_uploads: Vec<StaticUpload>,
140    },
141    QuotaExceeded {
142        reason: String,
143    },
144    NotLoggedIn,
145    NotFound,
146    InternalError,
147}
148
149#[derive(Deserialize)]
150struct StaticUpload {
151    path: String,
152    presigned_url: String,
153}
154
155#[derive(Serialize)]
156struct DeployStatusInput<'a> {
157    project_id: &'a str,
158    code_version: u64,
159}
160
161#[derive(Deserialize)]
162#[serde(tag = "t", rename_all_fields = "camelCase")]
163enum DeployStatus {
164    Done {
165        active_version: String,
166        pending_version: Option<String>,
167        pending_compiled: bool,
168        compiled_versions: Vec<String>,
169    },
170    Pending {
171        active_version: String,
172        pending_version: Option<String>,
173        pending_compiled: bool,
174        compiled_versions: Vec<String>,
175    },
176    NoActiveVersion,
177    NotLoggedIn,
178    NotFound,
179    InternalError,
180}
181
182#[allow(clippy::too_many_arguments)]
183pub async fn deploy_wasm(
184    control_url: &str,
185    token: &str,
186    project_id: &str,
187    build_id: &str,
188    bundle_tar_path: &Path,
189    jobs: &[CronJob],
190    cron_updated_at: &str,
191) -> Result<()> {
192    let client = reqwest::Client::new();
193    println!("project_id: {project_id}");
194
195    let DeployOk {
196        presigned_put_url,
197        object_key,
198        static_uploads: _,
199    } = request_deploy(
200        &client,
201        control_url,
202        token,
203        project_id,
204        build_id,
205        Vec::new(),
206        jobs,
207        cron_updated_at,
208    )
209    .await?;
210
211    println!("uploading bundle to {object_key}...");
212    let code_version = upload_bundle(&client, &presigned_put_url, bundle_tar_path).await?;
213    println!("uploaded. code_version={code_version}");
214
215    poll_deploy_status(&client, control_url, token, project_id, code_version).await?;
216    println!("Deploy complete!");
217    Ok(())
218}
219
220struct DeployOk {
221    presigned_put_url: String,
222    object_key: String,
223    static_uploads: Vec<StaticUpload>,
224}
225
226#[allow(clippy::too_many_arguments)]
227pub async fn deploy_forte(
228    control_url: &str,
229    token: &str,
230    project_id: &str,
231    build_id: &str,
232    fe_dist_dir: &Path,
233    bundle_tar_path: &Path,
234    jobs: &[CronJob],
235    cron_updated_at: &str,
236) -> Result<()> {
237    let client = reqwest::Client::new();
238    println!("project_id: {project_id}");
239
240    let static_files = collect_static_files(fe_dist_dir)?;
241    let deploy_files: Vec<DeployFile> = static_files
242        .iter()
243        .map(|f| DeployFile {
244            path: f.relative_path.clone(),
245            size: f.size,
246        })
247        .collect();
248    println!(
249        "Requesting deploy ({} static asset(s))...",
250        deploy_files.len()
251    );
252
253    let DeployOk {
254        presigned_put_url,
255        object_key,
256        static_uploads,
257    } = request_deploy(
258        &client,
259        control_url,
260        token,
261        project_id,
262        build_id,
263        deploy_files,
264        jobs,
265        cron_updated_at,
266    )
267    .await?;
268
269    if !static_files.is_empty() {
270        println!("Uploading {} static asset(s)...", static_files.len());
271        upload_static_assets(&client, &static_files, static_uploads).await?;
272    }
273
274    println!("uploading bundle to {object_key}...");
275    let code_version = upload_bundle(&client, &presigned_put_url, bundle_tar_path).await?;
276    println!("uploaded. code_version={code_version}");
277
278    poll_deploy_status(&client, control_url, token, project_id, code_version).await?;
279    println!("Deploy complete!");
280    Ok(())
281}
282
283#[allow(clippy::too_many_arguments)]
284async fn request_deploy(
285    client: &reqwest::Client,
286    control_url: &str,
287    token: &str,
288    project_id: &str,
289    build_id: &str,
290    files: Vec<DeployFile>,
291    jobs: &[CronJob],
292    cron_updated_at: &str,
293) -> Result<DeployOk> {
294    let deploy_url = format!(
295        "{}/__forte_action/deploy",
296        control_url.trim_end_matches('/')
297    );
298    let raw: Deploy = client
299        .post(&deploy_url)
300        .bearer_auth(token)
301        .json(&DeployInput {
302            project_id,
303            build_id,
304            files,
305            jobs,
306            cron_updated_at,
307        })
308        .send()
309        .await?
310        .error_for_status()
311        .map_err(|e| anyhow!("deploy failed: {e}"))?
312        .json()
313        .await?;
314    match raw {
315        Deploy::Ok {
316            presigned_put_url,
317            object_key,
318            static_uploads,
319        } => Ok(DeployOk {
320            presigned_put_url,
321            object_key,
322            static_uploads,
323        }),
324        Deploy::QuotaExceeded { reason } => Err(anyhow!("deploy quota exceeded: {reason}")),
325        Deploy::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
326        Deploy::NotFound => Err(anyhow!("project '{project_id}' not found or not owned by you.")),
327        Deploy::InternalError => Err(anyhow!("deploy: server error; check fn0-control logs")),
328    }
329}
330
331async fn upload_bundle(
332    client: &reqwest::Client,
333    presigned_put_url: &str,
334    bundle_tar_path: &Path,
335) -> Result<u64> {
336    let bundle_bytes = std::fs::read(bundle_tar_path)
337        .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
338    let put_resp = client
339        .put(presigned_put_url)
340        .body(bundle_bytes)
341        .send()
342        .await?
343        .error_for_status()
344        .map_err(|e| anyhow!("bundle upload failed: {e}"))?;
345    extract_code_version(&put_resp)
346}
347
348async fn upload_static_assets(
349    client: &reqwest::Client,
350    files: &[StaticFile],
351    uploads: Vec<StaticUpload>,
352) -> Result<()> {
353    use futures::StreamExt;
354    use std::collections::HashMap;
355
356    let mut url_for_path: HashMap<String, String> = HashMap::new();
357    for u in uploads {
358        url_for_path.insert(u.path, u.presigned_url);
359    }
360
361    let mut tasks = futures::stream::FuturesUnordered::new();
362    for file in files {
363        let url = url_for_path.remove(&file.relative_path).ok_or_else(|| {
364            anyhow!(
365                "control did not return presigned URL for {}",
366                file.relative_path
367            )
368        })?;
369        let bytes = std::fs::read(&file.absolute_path)
370            .map_err(|e| anyhow!("read {}: {}", file.absolute_path.display(), e))?;
371        let client = client.clone();
372        let content_type = file.content_type;
373        let path = file.relative_path.clone();
374        tasks.push(async move {
375            let resp = client
376                .put(&url)
377                .header("content-type", content_type)
378                .body(bytes)
379                .send()
380                .await
381                .map_err(|e| anyhow!("R2 PUT {}: {}", path, e))?;
382            resp.error_for_status()
383                .map_err(|e| anyhow!("R2 PUT {} HTTP error: {}", path, e))?;
384            Ok::<_, anyhow::Error>(())
385        });
386    }
387    while let Some(result) = tasks.next().await {
388        result?;
389    }
390    Ok(())
391}
392
393pub struct StaticFile {
394    pub relative_path: String,
395    pub absolute_path: PathBuf,
396    pub size: u64,
397    pub content_type: &'static str,
398}
399
400pub fn collect_static_files(dir: &Path) -> Result<Vec<StaticFile>> {
401    let mut out = Vec::new();
402    if !dir.exists() {
403        return Ok(out);
404    }
405    walk_collect(dir, dir, &mut out)?;
406    Ok(out)
407}
408
409fn walk_collect(base: &Path, dir: &Path, out: &mut Vec<StaticFile>) -> Result<()> {
410    for entry in std::fs::read_dir(dir)? {
411        let entry = entry?;
412        let path = entry.path();
413        if path.is_dir() {
414            if path.file_name().and_then(|s| s.to_str()) == Some("ssr")
415                && path.parent() == Some(base)
416            {
417                continue;
418            }
419            walk_collect(base, &path, out)?;
420            continue;
421        }
422        let metadata = entry.metadata()?;
423        let rel = path
424            .strip_prefix(base)
425            .map_err(|e| anyhow!("strip_prefix: {e}"))?
426            .to_string_lossy()
427            .replace('\\', "/");
428        out.push(StaticFile {
429            relative_path: rel,
430            absolute_path: path.clone(),
431            size: metadata.len(),
432            content_type: content_type_for(&path),
433        });
434    }
435    Ok(())
436}
437
438pub fn content_type_for(path: &Path) -> &'static str {
439    match path.extension().and_then(|e| e.to_str()) {
440        Some("html") => "text/html; charset=utf-8",
441        Some("css") => "text/css; charset=utf-8",
442        Some("js") | Some("mjs") | Some("cjs") => "application/javascript; charset=utf-8",
443        Some("json") => "application/json; charset=utf-8",
444        Some("map") => "application/json; charset=utf-8",
445        Some("png") => "image/png",
446        Some("jpg") | Some("jpeg") => "image/jpeg",
447        Some("gif") => "image/gif",
448        Some("svg") => "image/svg+xml",
449        Some("ico") => "image/x-icon",
450        Some("webp") => "image/webp",
451        Some("woff") => "font/woff",
452        Some("woff2") => "font/woff2",
453        Some("ttf") => "font/ttf",
454        Some("otf") => "font/otf",
455        Some("eot") => "application/vnd.ms-fontobject",
456        Some("txt") => "text/plain; charset=utf-8",
457        Some("xml") => "application/xml; charset=utf-8",
458        Some("pdf") => "application/pdf",
459        Some("mp4") => "video/mp4",
460        Some("webm") => "video/webm",
461        Some("mp3") => "audio/mpeg",
462        Some("wav") => "audio/wav",
463        _ => "application/octet-stream",
464    }
465}
466
467fn extract_code_version(resp: &reqwest::Response) -> Result<u64> {
468    let hv = resp
469        .headers()
470        .get(reqwest::header::LAST_MODIFIED)
471        .ok_or_else(|| anyhow!("R2 PUT response missing Last-Modified header"))?
472        .to_str()
473        .map_err(|e| anyhow!("Last-Modified not utf-8: {e}"))?;
474    let dt = chrono::DateTime::parse_from_rfc2822(hv)
475        .map_err(|e| anyhow!("Last-Modified parse: {e}; raw={hv}"))?;
476    let secs = dt.timestamp();
477    u64::try_from(secs).map_err(|_| anyhow!("Last-Modified before epoch: {secs}"))
478}
479
480async fn poll_deploy_status(
481    client: &reqwest::Client,
482    control_url: &str,
483    token: &str,
484    project_id: &str,
485    code_version: u64,
486) -> Result<()> {
487    let url = format!(
488        "{}/__forte_action/deploy_status",
489        control_url.trim_end_matches('/')
490    );
491    let timeout = std::time::Duration::from_secs(600);
492    let start = std::time::Instant::now();
493    let mut last_state: Option<String> = None;
494
495    loop {
496        let raw: DeployStatus = client
497            .post(&url)
498            .bearer_auth(token)
499            .json(&DeployStatusInput {
500                project_id,
501                code_version,
502            })
503            .send()
504            .await?
505            .error_for_status()
506            .map_err(|e| anyhow!("deploy_status failed: {e}"))?
507            .json()
508            .await?;
509
510        match raw {
511            DeployStatus::Done {
512                active_version,
513                pending_version,
514                pending_compiled,
515                compiled_versions,
516            } => {
517                log_status_line(
518                    &active_version,
519                    &compiled_versions,
520                    &pending_version,
521                    pending_compiled,
522                    &mut last_state,
523                );
524                return Ok(());
525            }
526            DeployStatus::Pending {
527                active_version,
528                pending_version,
529                pending_compiled,
530                compiled_versions,
531            } => {
532                log_status_line(
533                    &active_version,
534                    &compiled_versions,
535                    &pending_version,
536                    pending_compiled,
537                    &mut last_state,
538                );
539                if start.elapsed() > timeout {
540                    return Err(anyhow!(
541                        "deploy_status timed out after {}s",
542                        timeout.as_secs()
543                    ));
544                }
545            }
546            DeployStatus::NoActiveVersion => {
547                return Err(anyhow!("control has no active fn0-wasmtime version yet"));
548            }
549            DeployStatus::NotLoggedIn => {
550                return Err(anyhow!("control rejected token; run `fn0 login` again."));
551            }
552            DeployStatus::NotFound => {
553                return Err(anyhow!(
554                    "project '{project_id}' not found or not owned by you."
555                ));
556            }
557            DeployStatus::InternalError => {
558                return Err(anyhow!(
559                    "deploy_status: server error; check fn0-control logs"
560                ));
561            }
562        }
563    }
564}
565
566fn log_status_line(
567    active_version: &str,
568    compiled_versions: &[String],
569    pending_version: &Option<String>,
570    pending_compiled: bool,
571    last_state: &mut Option<String>,
572) {
573    let state = format!(
574        "active={active_version} compiled={compiled_versions:?} pending={pending_version:?} pending_compiled={pending_compiled}",
575    );
576    if last_state.as_deref() != Some(&state) {
577        println!("  {state}");
578        *last_state = Some(state);
579    }
580}
581
582pub fn read_env_yaml(project_dir: &Path) -> Result<Option<Vec<u8>>> {
583    let p = project_dir.join("env.yaml");
584    match std::fs::read(&p) {
585        Ok(content) => Ok(Some(content)),
586        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
587        Err(e) => Err(anyhow!("Failed to read {}: {}", p.display(), e)),
588    }
589}
590
591pub fn create_raw_bundle_wasm(
592    wasm_path: &Path,
593    env_yaml: Option<&[u8]>,
594    output_path: &Path,
595) -> Result<()> {
596    let file = std::fs::File::create(output_path)
597        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
598    let mut builder = tar::Builder::new(file);
599    append_bytes(&mut builder, "manifest.json", br#"{"kind":"wasm"}"#)?;
600    let wasm_bytes = std::fs::read(wasm_path)
601        .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
602    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
603    if let Some(env) = env_yaml {
604        append_bytes(&mut builder, "env.yaml", env)?;
605    }
606    builder.finish()?;
607    Ok(())
608}
609
610pub fn create_raw_bundle_forte(
611    dist_dir: &Path,
612    env_yaml: Option<&[u8]>,
613    output_path: &Path,
614) -> Result<()> {
615    let file = std::fs::File::create(output_path)
616        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
617    let mut builder = tar::Builder::new(file);
618    append_bytes(&mut builder, "manifest.json", br#"{"kind":"wasmjs"}"#)?;
619
620    let backend_wasm = dist_dir.join("backend.wasm");
621    let wasm_bytes = std::fs::read(&backend_wasm)
622        .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
623    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
624
625    let server_js = dist_dir.join("server.js");
626    let server_bytes = std::fs::read(&server_js)
627        .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
628    append_bytes(&mut builder, "entry.js", &server_bytes)?;
629
630    if let Some(env) = env_yaml {
631        append_bytes(&mut builder, "env.yaml", env)?;
632    }
633
634    builder.finish()?;
635    Ok(())
636}
637
638fn append_bytes<W: std::io::Write>(
639    builder: &mut tar::Builder<W>,
640    path: &str,
641    data: &[u8],
642) -> Result<()> {
643    let mut header = tar::Header::new_gnu();
644    header.set_size(data.len() as u64);
645    header.set_mode(0o644);
646    header.set_cksum();
647    builder
648        .append_data(&mut header, path, data)
649        .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
650    Ok(())
651}
652
653pub struct AdminRunOutput {
654    pub status: u16,
655    pub content_type: Option<String>,
656    pub body: Vec<u8>,
657}
658
659pub async fn admin_run(
660    _project_id: &str,
661    _task: &str,
662    _input_body: Vec<u8>,
663    _timeout_secs: u64,
664) -> Result<AdminRunOutput> {
665    Err(anyhow!(
666        "admin run is not yet migrated to control. See GitHub issue #4."
667    ))
668}
669
670#[derive(Serialize)]
671struct DomainAddInput<'a> {
672    project_id: &'a str,
673    domain: &'a str,
674}
675
676#[derive(Deserialize)]
677#[serde(tag = "t", rename_all_fields = "camelCase")]
678enum DomainAdd {
679    Ok,
680    NotLoggedIn,
681    NotFound,
682    InvalidDomain { message: String },
683    DomainTaken { existing_project_id: String },
684    AlreadyHasDomain { current_domain: String },
685    InternalError,
686}
687
688pub async fn domain_add(project_id: &str, domain: &str) -> Result<()> {
689    let creds = credentials::require()?;
690    let client = reqwest::Client::new();
691    let url = format!(
692        "{}/__forte_action/domain_add",
693        creds.control_url.trim_end_matches('/')
694    );
695    let resp = client
696        .post(&url)
697        .bearer_auth(&creds.token)
698        .json(&DomainAddInput { project_id, domain })
699        .send()
700        .await?
701        .error_for_status()?;
702    let raw: DomainAdd = resp.json().await?;
703    match raw {
704        DomainAdd::Ok => {
705            println!("domain '{domain}' attached to project '{project_id}'");
706            println!(
707                "Cloudflare hostname registration is queued; run `fn0 domain status` to check."
708            );
709            Ok(())
710        }
711        DomainAdd::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
712        DomainAdd::NotFound => Err(anyhow!(
713            "project '{project_id}' not found or not owned by you."
714        )),
715        DomainAdd::InvalidDomain { message } => Err(anyhow!("invalid domain: {message}")),
716        DomainAdd::DomainTaken {
717            existing_project_id,
718        } => Err(anyhow!(
719            "domain '{domain}' already in use by project '{existing_project_id}'"
720        )),
721        DomainAdd::AlreadyHasDomain { current_domain } => Err(anyhow!(
722            "project '{project_id}' already has domain '{current_domain}'; remove it first"
723        )),
724        DomainAdd::InternalError => Err(anyhow!("domain_add: server error; check fn0-control logs")),
725    }
726}
727
728#[derive(Serialize)]
729struct DomainProjectInput<'a> {
730    project_id: &'a str,
731}
732
733#[derive(Deserialize)]
734#[serde(tag = "t", rename_all_fields = "camelCase")]
735enum DomainRemove {
736    Ok { removed_domain: String },
737    NotLoggedIn,
738    NotFound,
739    NoDomain,
740    InternalError,
741}
742
743pub async fn domain_remove(project_id: &str) -> Result<()> {
744    let creds = credentials::require()?;
745    let client = reqwest::Client::new();
746    let url = format!(
747        "{}/__forte_action/domain_remove",
748        creds.control_url.trim_end_matches('/')
749    );
750    let resp = client
751        .post(&url)
752        .bearer_auth(&creds.token)
753        .json(&DomainProjectInput { project_id })
754        .send()
755        .await?
756        .error_for_status()?;
757    let raw: DomainRemove = resp.json().await?;
758    match raw {
759        DomainRemove::Ok { removed_domain } => {
760            println!("domain '{removed_domain}' detached from project '{project_id}'");
761            println!("Cloudflare hostname removal is queued.");
762            Ok(())
763        }
764        DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
765        DomainRemove::NotFound => Err(anyhow!(
766            "project '{project_id}' not found or not owned by you."
767        )),
768        DomainRemove::NoDomain => Err(anyhow!(
769            "no custom domain attached to project '{project_id}'."
770        )),
771        DomainRemove::InternalError => Err(anyhow!(
772            "domain_remove: server error; check fn0-control logs"
773        )),
774    }
775}
776
777#[derive(Deserialize)]
778#[serde(tag = "t", rename_all_fields = "camelCase")]
779enum DomainStatus {
780    NotConfigured,
781    Configured {
782        domain: String,
783        cloudflare_status: CloudflareStatus,
784    },
785    NotLoggedIn,
786    NotFound,
787    InternalError,
788}
789
790#[derive(Deserialize)]
791#[serde(tag = "t", rename_all_fields = "camelCase")]
792enum CloudflareStatus {
793    Active,
794    Pending,
795    Missing,
796    Other { value: String },
797}
798
799pub async fn domain_status(project_id: &str) -> Result<()> {
800    let creds = credentials::require()?;
801    let client = reqwest::Client::new();
802    let url = format!(
803        "{}/__forte_action/domain_status",
804        creds.control_url.trim_end_matches('/')
805    );
806    let resp = client
807        .post(&url)
808        .bearer_auth(&creds.token)
809        .json(&DomainProjectInput { project_id })
810        .send()
811        .await?
812        .error_for_status()?;
813    let raw: DomainStatus = resp.json().await?;
814    match raw {
815        DomainStatus::NotConfigured => {
816            println!("project '{project_id}' has no custom domain configured.");
817            Ok(())
818        }
819        DomainStatus::Configured {
820            domain,
821            cloudflare_status,
822        } => {
823            println!("project '{project_id}' custom domain: {domain}");
824            println!(
825                "cloudflare status: {}",
826                format_cloudflare_status(&cloudflare_status)
827            );
828            Ok(())
829        }
830        DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
831        DomainStatus::NotFound => Err(anyhow!(
832            "project '{project_id}' not found or not owned by you."
833        )),
834        DomainStatus::InternalError => Err(anyhow!(
835            "domain_status: server error; check fn0-control logs"
836        )),
837    }
838}
839
840fn format_cloudflare_status(status: &CloudflareStatus) -> String {
841    match status {
842        CloudflareStatus::Active => "active".to_string(),
843        CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
844        CloudflareStatus::Missing => {
845            "missing on Cloudflare (registration may still be in progress)".to_string()
846        }
847        CloudflareStatus::Other { value } => format!("other: {value}"),
848    }
849}
850
851#[derive(Serialize)]
852struct RenameProjectInput<'a> {
853    project_id: &'a str,
854    new_name: &'a str,
855}
856
857#[derive(Deserialize)]
858#[serde(tag = "t", rename_all_fields = "camelCase")]
859enum RenameProject {
860    Ok,
861    NotLoggedIn,
862    NotFound,
863    InvalidName,
864    InternalError,
865}
866
867pub async fn rename_project(project_id: &str, new_name: &str) -> Result<()> {
868    let creds = credentials::require()?;
869    let client = reqwest::Client::new();
870    let url = format!(
871        "{}/__forte_action/rename_project",
872        creds.control_url.trim_end_matches('/')
873    );
874    let resp = client
875        .post(&url)
876        .bearer_auth(&creds.token)
877        .json(&RenameProjectInput {
878            project_id,
879            new_name,
880        })
881        .send()
882        .await?
883        .error_for_status()?;
884    let raw: RenameProject = resp.json().await?;
885    match raw {
886        RenameProject::Ok => Ok(()),
887        RenameProject::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
888        RenameProject::NotFound => Err(anyhow!(
889            "project '{project_id}' not found or not owned by you."
890        )),
891        RenameProject::InvalidName => Err(anyhow!(
892            "control rejected name '{new_name}': must be 1-{MAX_PROJECT_NAME_LEN} chars of letters, digits, '.', '_', '-'"
893        )),
894        RenameProject::InternalError => Err(anyhow!(
895            "rename_project: server error; check fn0-control logs"
896        )),
897    }
898}