fn0-deploy 0.1.10

Deploy client for fn0 cloud
Documentation
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct DomainAddInput<'a> {
    project_id: &'a str,
    domain: &'a str,
}

#[derive(Deserialize)]
#[serde(tag = "t", rename_all_fields = "camelCase")]
enum DomainAdd {
    Ok,
    NotLoggedIn,
    NotFound,
    InvalidDomain { message: String },
    DomainTaken { existing_project_id: String },
    AlreadyHasDomain { current_domain: String },
    InternalError,
}

pub async fn domain_add(project_id: &str, domain: &str) -> Result<()> {
    let creds = crate::credentials::require()?;
    let client = reqwest::Client::new();
    let url = format!(
        "{}/__forte_action/domain_add",
        creds.control_url.trim_end_matches('/')
    );
    let resp = client
        .post(&url)
        .bearer_auth(&creds.token)
        .json(&DomainAddInput { project_id, domain })
        .send()
        .await?
        .error_for_status()?;
    let raw: DomainAdd = resp.json().await?;
    match raw {
        DomainAdd::Ok => {
            println!("domain '{domain}' attached to project '{project_id}'");
            println!(
                "Cloudflare hostname registration is queued; run `fn0 domain status` to check."
            );
            Ok(())
        }
        DomainAdd::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
        DomainAdd::NotFound => Err(anyhow!(
            "project '{project_id}' not found or not owned by you."
        )),
        DomainAdd::InvalidDomain { message } => Err(anyhow!("invalid domain: {message}")),
        DomainAdd::DomainTaken {
            existing_project_id,
        } => Err(anyhow!(
            "domain '{domain}' already in use by project '{existing_project_id}'"
        )),
        DomainAdd::AlreadyHasDomain { current_domain } => Err(anyhow!(
            "project '{project_id}' already has domain '{current_domain}'; remove it first"
        )),
        DomainAdd::InternalError => Err(anyhow!("domain_add: server error; check fn0-control logs")),
    }
}

#[derive(Serialize)]
struct DomainProjectInput<'a> {
    project_id: &'a str,
}

#[derive(Deserialize)]
#[serde(tag = "t", rename_all_fields = "camelCase")]
enum DomainRemove {
    Ok { removed_domain: String },
    NotLoggedIn,
    NotFound,
    NoDomain,
    InternalError,
}

pub async fn domain_remove(project_id: &str) -> Result<()> {
    let creds = crate::credentials::require()?;
    let client = reqwest::Client::new();
    let url = format!(
        "{}/__forte_action/domain_remove",
        creds.control_url.trim_end_matches('/')
    );
    let resp = client
        .post(&url)
        .bearer_auth(&creds.token)
        .json(&DomainProjectInput { project_id })
        .send()
        .await?
        .error_for_status()?;
    let raw: DomainRemove = resp.json().await?;
    match raw {
        DomainRemove::Ok { removed_domain } => {
            println!("domain '{removed_domain}' detached from project '{project_id}'");
            println!("Cloudflare hostname removal is queued.");
            Ok(())
        }
        DomainRemove::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
        DomainRemove::NotFound => Err(anyhow!(
            "project '{project_id}' not found or not owned by you."
        )),
        DomainRemove::NoDomain => Err(anyhow!(
            "no custom domain attached to project '{project_id}'."
        )),
        DomainRemove::InternalError => Err(anyhow!(
            "domain_remove: server error; check fn0-control logs"
        )),
    }
}

#[derive(Deserialize)]
#[serde(tag = "t", rename_all_fields = "camelCase")]
enum DomainStatus {
    NotConfigured,
    Configured {
        domain: String,
        cloudflare_status: CloudflareStatus,
    },
    NotLoggedIn,
    NotFound,
    InternalError,
}

#[derive(Deserialize)]
#[serde(tag = "t", rename_all_fields = "camelCase")]
enum CloudflareStatus {
    Active,
    Pending,
    Missing,
    Other { value: String },
}

pub async fn domain_status(project_id: &str) -> Result<()> {
    let creds = crate::credentials::require()?;
    let client = reqwest::Client::new();
    let url = format!(
        "{}/__forte_action/domain_status",
        creds.control_url.trim_end_matches('/')
    );
    let resp = client
        .post(&url)
        .bearer_auth(&creds.token)
        .json(&DomainProjectInput { project_id })
        .send()
        .await?
        .error_for_status()?;
    let raw: DomainStatus = resp.json().await?;
    match raw {
        DomainStatus::NotConfigured => {
            println!("project '{project_id}' has no custom domain configured.");
            Ok(())
        }
        DomainStatus::Configured {
            domain,
            cloudflare_status,
        } => {
            println!("project '{project_id}' custom domain: {domain}");
            println!(
                "cloudflare status: {}",
                format_cloudflare_status(&cloudflare_status)
            );
            Ok(())
        }
        DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
        DomainStatus::NotFound => Err(anyhow!(
            "project '{project_id}' not found or not owned by you."
        )),
        DomainStatus::InternalError => Err(anyhow!(
            "domain_status: server error; check fn0-control logs"
        )),
    }
}

fn format_cloudflare_status(status: &CloudflareStatus) -> String {
    match status {
        CloudflareStatus::Active => "active".to_string(),
        CloudflareStatus::Pending => "pending (waiting for DV verification)".to_string(),
        CloudflareStatus::Missing => {
            "missing on Cloudflare (registration may still be in progress)".to_string()
        }
        CloudflareStatus::Other { value } => format!("other: {value}"),
    }
}