forte-cli 0.6.1

CLI for the Forte fullstack web framework
use anyhow::{Result, anyhow};
use std::path::PathBuf;

use super::project_config::{read_cloud_config, write_cloud_config};
use fn0_deploy::cloudflare_provision::{ProvisionedResources, ReachableZone, ZoneDiscovery};
use fn0_deploy::{
    CloudSetup, CloudflareConnection, DomainStatus, fetch_cloudflare_connection,
    provision_and_connect, set_domain,
};

pub async fn init(
    project_dir: PathBuf,
    requested_project_name: Option<String>,
    requested_zone: Option<String>,
) -> Result<()> {
    let api_token = read_cloudflare_token()?;
    let config = read_cloud_config(&project_dir)?;
    let has_project_id = config.project_id.is_some();
    let project_name = resolve_setting(
        requested_project_name,
        config.project_name,
        "--project-name",
        has_project_id,
    )?;
    validate_project_name(&project_name)?;
    let zone_name = resolve_setting(requested_zone, config.zone, "--zone", has_project_id)?;
    validate_zone_name(&zone_name)?;
    let domain = derive_domain(&project_name, &zone_name)?;
    if has_project_id
        && let Some(stored_domain) = config.domain.as_deref()
        && stored_domain != domain
    {
        return Err(anyhow!(
            "Forte.toml domain '{stored_domain}' does not match the derived domain '{domain}'."
        ));
    }

    let creds = fn0_deploy::credentials::load()?.ok_or_else(|| {
        anyhow!(
            "not signed in. Run `forte login` first (credentials at {}).",
            fn0_deploy::credentials::path()
                .map(|path| path.display().to_string())
                .unwrap_or_default()
        )
    })?;

    println!("reading the zones this token can reach...");
    let zones = ZoneDiscovery::new(api_token.clone()).list(true).await?;
    let zone = resolve_zone(zones, &zone_name)?;

    let mut project_id = config.project_id;
    let project_id = fn0_deploy::ensure_project_id(
        &reqwest::Client::new(),
        &creds.control_url,
        &creds.token,
        &project_name,
        &mut project_id,
    )
    .await?;
    write_cloud_config(
        &project_dir,
        &project_id,
        &project_name,
        &zone_name,
        &domain,
    )?;

    let connection = if has_project_id {
        fetch_cloudflare_connection(&project_id).await?
    } else {
        CloudflareConnection::NotConnected
    };
    validate_existing_connection(&creds, &project_id, &connection, &zone, &domain).await?;

    let setup = CloudSetup {
        project_id: &project_id,
        account_id: &zone.account_id,
        zone_id: &zone.zone_id,
        api_token: &api_token,
        mint_from_setup_token: true,
        domain: &domain,
    };

    match connection {
        CloudflareConnection::NotConnected => {
            println!("  provisioning your Cloudflare account (this runs locally)...");
            let resources = provision_and_connect(&setup).await?;
            print_resources(&resources);
            println!("  minted two bucket-scoped R2 tokens and a purge-only token");
            println!("  connected");
        }
        CloudflareConnection::Connected { .. } => {
            println!("  Cloudflare account already connected");
        }
        CloudflareConnection::NotFound => {
            return Err(anyhow!(
                "project '{project_id}' not found or not owned by you."
            ));
        }
    }

    let outcome = set_domain(&setup).await?;
    match &outcome.replaced_domain {
        Some(replaced) => println!("  domain {domain} registered, replacing {replaced}"),
        None => println!("  domain {domain} registered"),
    }

    println!();
    println!("Add this proxied record in your Cloudflare DNS:");
    println!();
    println!(
        "    CNAME   {domain}   {}   (proxied / orange cloud)",
        outcome.origin_hostname
    );
    println!();
    println!("It must stay proxied: the origin certificate is trusted by Cloudflare's");
    println!("edge only, so turning the record grey breaks the hostname.");
    println!();
    println!("Then: forte deploy");
    Ok(())
}

fn read_cloudflare_token() -> Result<String> {
    let token = std::env::var("CLOUDFLARE_API_TOKEN").map_err(|_| {
        anyhow!("CLOUDFLARE_API_TOKEN is required. Set it before running `forte cloud init`.")
    })?;
    let trimmed_token = token.trim();
    if trimmed_token.is_empty() {
        return Err(anyhow!(
            "CLOUDFLARE_API_TOKEN cannot be empty. Set it before running `forte cloud init`."
        ));
    }
    Ok(trimmed_token.to_string())
}

fn resolve_setting(
    requested_value: Option<String>,
    stored_value: Option<String>,
    option_name: &str,
    has_project_id: bool,
) -> Result<String> {
    match (requested_value, stored_value) {
        (Some(requested_value), Some(stored_value)) => {
            if requested_value != stored_value {
                return Err(anyhow!(
                    "Forte.toml value '{stored_value}' does not match {option_name} '{requested_value}'."
                ));
            }
            Ok(stored_value)
        }
        (Some(requested_value), None) => Ok(requested_value),
        (None, Some(stored_value)) => Ok(stored_value),
        (None, None) if has_project_id => Err(anyhow!(
            "Forte.toml is missing {option_name}. Pass {option_name} to continue without interactive input."
        )),
        (None, None) => Err(anyhow!("{option_name} is required for a new project.")),
    }
}

fn validate_project_name(project_name: &str) -> Result<()> {
    if project_name.is_empty() || project_name.len() > 63 {
        return Err(anyhow!(
            "project name '{project_name}' must be 1 to 63 ASCII characters"
        ));
    }
    let Some(first_character) = project_name.bytes().next() else {
        return Err(anyhow!(
            "project name '{project_name}' must start with a letter or digit"
        ));
    };
    let Some(last_character) = project_name.bytes().last() else {
        return Err(anyhow!(
            "project name '{project_name}' must end with a letter or digit"
        ));
    };
    if !is_dns_label_edge_character(first_character) || !is_dns_label_edge_character(last_character)
    {
        return Err(anyhow!(
            "project name '{project_name}' must start and end with a lowercase letter or digit"
        ));
    }
    if project_name
        .bytes()
        .any(|character| !is_dns_label_character(character))
    {
        return Err(anyhow!(
            "project name '{project_name}' must contain only lowercase letters, digits, and hyphens"
        ));
    }
    Ok(())
}

fn is_dns_label_character(character: u8) -> bool {
    character.is_ascii_lowercase() || character.is_ascii_digit() || character == b'-'
}

fn is_dns_label_edge_character(character: u8) -> bool {
    character.is_ascii_lowercase() || character.is_ascii_digit()
}

fn validate_zone_name(zone_name: &str) -> Result<()> {
    if zone_name.is_empty() || zone_name.chars().any(|character| character.is_whitespace()) {
        return Err(anyhow!("zone name must be a non-empty hostname"));
    }
    Ok(())
}

fn derive_domain(project_name: &str, zone_name: &str) -> Result<String> {
    let domain = format!("{project_name}.{zone_name}");
    if domain.len() > 253 {
        return Err(anyhow!(
            "derived domain '{domain}' exceeds the 253-character DNS hostname limit"
        ));
    }
    Ok(domain)
}

fn resolve_zone(zones: Vec<ReachableZone>, zone_name: &str) -> Result<ReachableZone> {
    let matching_zones: Vec<ReachableZone> = zones
        .into_iter()
        .filter(|zone| zone.zone_name == zone_name)
        .collect();
    match matching_zones.len() {
        0 => Err(anyhow!(
            "Cloudflare zone '{zone_name}' was not found or is not accessible to CLOUDFLARE_API_TOKEN."
        )),
        1 => matching_zones
            .into_iter()
            .next()
            .ok_or_else(|| anyhow!("Cloudflare zone '{zone_name}' could not be resolved")),
        _ => {
            let account_names: Vec<&str> = matching_zones
                .iter()
                .map(|zone| zone.account_name.as_str())
                .collect();
            Err(anyhow!(
                "Cloudflare zone '{zone_name}' exists in multiple accounts: {}",
                account_names.join(", ")
            ))
        }
    }
}

async fn validate_existing_connection(
    creds: &fn0_deploy::credentials::Credentials,
    project_id: &str,
    connection: &CloudflareConnection,
    zone: &ReachableZone,
    domain: &str,
) -> Result<()> {
    let CloudflareConnection::Connected { zone_name } = connection else {
        if let CloudflareConnection::NotFound = connection {
            return Err(anyhow!(
                "project '{project_id}' not found or not owned by you."
            ));
        }
        return Ok(());
    };

    if zone_name != &zone.zone_name {
        return Err(anyhow!(
            "project '{project_id}' is connected to zone '{zone_name}', not requested zone '{}'. Reconnecting is not supported.",
            zone.zone_name
        ));
    }

    match fn0_deploy::fetch_domain_status(creds, project_id).await? {
        DomainStatus::SelfHosted {
            domain: live_domain,
            ..
        } if live_domain != domain => Err(anyhow!(
            "project '{project_id}' is configured for domain '{live_domain}', not '{domain}'. Reconfiguration is not supported by `forte cloud init`."
        )),
        DomainStatus::NotLoggedIn => Err(anyhow!("control rejected token; sign in 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"
        )),
        _ => Ok(()),
    }
}

fn print_resources(resources: &ProvisionedResources) {
    println!("  zone     {}", resources.zone_name);
    println!("  buckets  {}", resources.private_object_storage_bucket);
    println!("           {}", resources.public_object_storage_bucket);
    println!("           {}", resources.frontend_asset_bucket);
    println!("  assets   https://{}", resources.frontend_asset_hostname);
    println!(
        "  public   https://{}",
        resources.public_object_storage_hostname
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepts_dns_label_project_names() {
        assert!(validate_project_name("my-app").is_ok());
        assert!(validate_project_name("a1").is_ok());
        let maximum_length_name = "a".repeat(63);
        assert!(validate_project_name(&maximum_length_name).is_ok());
    }

    #[test]
    fn rejects_invalid_dns_label_project_names() {
        for project_name in ["", "-my-app", "my-app-", "My-app", "my_app", "my.app"] {
            assert!(validate_project_name(project_name).is_err());
        }
        let too_long_name = "a".repeat(64);
        assert!(validate_project_name(&too_long_name).is_err());
    }

    #[test]
    fn derives_domain_from_project_name_and_zone() {
        assert_eq!(
            derive_domain("my-app", "example.com").unwrap(),
            "my-app.example.com"
        );
    }

    #[test]
    fn resolves_only_the_exact_zone_name() {
        let resolved_zone = resolve_zone(
            vec![ReachableZone {
                zone_id: "zone-id".to_string(),
                zone_name: "example.com".to_string(),
                account_id: "account-id".to_string(),
                account_name: "account".to_string(),
            }],
            "example.com",
        )
        .unwrap();
        assert_eq!(resolved_zone.zone_id, "zone-id");
        assert!(resolve_zone(Vec::new(), "other.example.com").is_err());
    }
}