flow-iron 0.4.3

Infrastructure-as-code CLI — deploy Docker Compose apps with Caddy reverse proxy and Cloudflare DNS
Documentation
use std::path::Path;

use anyhow::Result;
use toml_edit::{Array, DocumentMut, Item, Table};

use crate::ui;

pub async fn run(config_path: &str) -> Result<()> {
    let path = Path::new(config_path);

    if path.exists() {
        ui::success(&format!("{config_path} already exists"));
        return Ok(());
    }

    let mut doc = DocumentMut::new();

    ui::header("Initialize fleet.toml");

    if let Some(domain) = ui::prompt("Fleet domain (e.g. example.com):") {
        doc["domain"] = toml_edit::value(domain);
    }

    if let Some(zone_id) = ui::prompt("Cloudflare zone ID (skip if not using DNS):") {
        doc["cloudflare_zone_id"] = toml_edit::value(zone_id);

        if let Some(token) =
            ui::prompt_secret("Cloudflare API token (skip to set later with `flow login cf`):")
        {
            let env_path = Path::new(config_path).with_file_name("fleet.env.toml");
            validate_and_save_token(
                &token,
                crate::cloudflare::verify_token(&token),
                &env_path,
                "cloudflare_api_token",
                "flow login cf",
            )
            .await;
        }
    }

    if let Some(username) = ui::prompt("GitHub org/username for GHCR (e.g. flow-industries):") {
        let env_path = Path::new(config_path).with_file_name("fleet.env.toml");
        if let Err(e) = crate::login::save_fleet_secret(&env_path, "ghcr_username", &username) {
            ui::error(&format!("Failed to save username: {e}"));
        }
    }

    if let Some(token) =
        ui::prompt_secret("GitHub token for GHCR (skip to set later with `flow login gh`):")
    {
        let env_path = Path::new(config_path).with_file_name("fleet.env.toml");
        validate_and_save_token(
            &token,
            verify_gh(&token),
            &env_path,
            "ghcr_token",
            "flow login gh",
        )
        .await;
    }

    let mut server_names: Vec<String> = Vec::new();

    while ui::confirm("Add a server? (y/N)") {
        let Some(name) = ui::prompt("  Server name:") else {
            continue;
        };
        let Some(ip) = ui::prompt("  Server IP:") else {
            continue;
        };

        let mut server = Table::new();
        server["host"] = toml_edit::value(format!(
            "{name}.{}",
            doc.get("domain")
                .and_then(Item::as_str)
                .unwrap_or("example.com")
        ));
        server["ip"] = toml_edit::value(&ip);

        if doc.get("servers").is_none() {
            doc["servers"] = Item::Table(Table::new());
        }
        doc["servers"][&name] = Item::Table(server);
        server_names.push(name);
        println!();
    }

    while ui::confirm("Add an app? (y/N)") {
        let Some(name) = ui::prompt("  App name:") else {
            continue;
        };
        let Some(image) = ui::prompt("  Docker image:") else {
            continue;
        };

        let server = if server_names.len() == 1 {
            server_names[0].clone()
        } else {
            let hint = if server_names.is_empty() {
                String::new()
            } else {
                format!(" ({})", server_names.join(", "))
            };
            let Some(s) = ui::prompt(&format!("  Server{hint}:")) else {
                continue;
            };
            s
        };

        let mut app = Table::new();
        app["image"] = toml_edit::value(&image);

        let mut servers = Array::new();
        servers.push(&server);
        app["servers"] = toml_edit::value(servers);

        let port = ui::prompt("  Container port (skip if no HTTP routing):");

        if let Some(port_str) = &port {
            if let Ok(p) = port_str.parse::<i64>() {
                app["port"] = toml_edit::value(p);
            }
        }

        if port.is_some() {
            if let Some(route) = ui::prompt("  Route / domain (e.g. example.com):") {
                let mut routing = Table::new();
                let mut routes = Array::new();
                routes.push(&route);
                routing["routes"] = toml_edit::value(routes);
                app["routing"] = Item::Table(routing);
            }
        }

        if doc.get("apps").is_none() {
            doc["apps"] = Item::Table(Table::new());
        }
        doc["apps"][&name] = Item::Table(app);
        println!();
    }

    std::fs::write(path, doc.to_string())?;
    ui::success(&format!("Created {config_path}"));
    Ok(())
}

async fn verify_gh(token: &str) -> Result<()> {
    let client = reqwest::Client::new();
    let resp = client
        .get("https://api.github.com/user")
        .header("User-Agent", "flow-iron")
        .bearer_auth(token)
        .send()
        .await?;
    if !resp.status().is_success() {
        anyhow::bail!("Invalid GitHub token");
    }
    Ok(())
}

async fn validate_and_save_token(
    token: &str,
    validation: impl std::future::Future<Output = Result<()>>,
    env_path: &Path,
    key: &str,
    fallback_cmd: &str,
) {
    let sp = ui::spinner("Validating token...");
    match validation.await {
        Ok(()) => {
            sp.finish_and_clear();
            ui::success("Token is valid");
            if let Err(e) = crate::login::save_fleet_secret(env_path, key, token) {
                ui::error(&format!("Failed to save token: {e}"));
            }
        }
        Err(e) => {
            sp.finish_and_clear();
            ui::error(&format!("Token validation failed: {e}"));
            ui::error(&format!("Set it later with `{fallback_cmd}`"));
        }
    }
}