cufflink-cli 0.9.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::config::CliConfig;
use crate::project_config::ProjectConfig;
use std::collections::HashMap;
use std::process::Command;

/// Get the service ID by extracting manifest name and looking up via API
async fn get_service_id(config: &CliConfig) -> eyre::Result<String> {
    // Check if this is a web project first (use Cufflink.toml)
    let project = ProjectConfig::find_and_load()?;
    let mode = project
        .as_ref()
        .and_then(|p| p.service.mode.as_deref())
        .unwrap_or("rust");

    let service_name = if mode == "web" {
        project
            .as_ref()
            .and_then(|p| p.service.name.as_deref())
            .ok_or_else(|| eyre::eyre!("Web mode requires [service].name in Cufflink.toml"))?
            .to_string()
    } else {
        let output = Command::new("cargo")
            .args(["run", "--", "--emit-manifest"])
            .output()?;

        if !output.status.success() {
            eyre::bail!("Failed to build service. Run from a cufflink service directory.");
        }

        let stdout = String::from_utf8(output.stdout)?;
        let manifest: serde_json::Value = serde_json::from_str(stdout.trim())?;
        manifest["name"]
            .as_str()
            .ok_or_else(|| eyre::eyre!("No service name in manifest"))?
            .to_string()
    };
    let service_name = &service_name;

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!("{}/api/services", config.api_url),
        )
        .send()
        .await?;

    let services: serde_json::Value = resp.json().await?;
    let service = services["services"]
        .as_array()
        .and_then(|arr| {
            arr.iter()
                .find(|s| s["name"].as_str() == Some(service_name))
        })
        .ok_or_else(|| eyre::eyre!("Service '{}' not found on platform", service_name))?;

    Ok(service["id"]
        .as_str()
        .ok_or_else(|| eyre::eyre!("Service has no ID"))?
        .to_string())
}

/// List all config values for the current service
pub async fn list(env: Option<&str>) -> eyre::Result<()> {
    let config = CliConfig::load_with_env(env)?;

    if let Some(ref name) = config.env_name {
        println!("Environment: {}", name);
    }

    let service_id = get_service_id(&config).await?;

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!("{}/api/services/{}/config", config.api_url, service_id),
        )
        .send()
        .await?;

    if resp.status().is_success() {
        let body: serde_json::Value = resp.json().await?;
        if let Some(configs) = body["configs"].as_array() {
            if configs.is_empty() {
                println!("No configuration values set.");
            } else {
                use comfy_table::{presets::NOTHING, Table, TableComponent};

                let mut table = Table::new();
                table.load_preset(NOTHING);
                table.set_style(TableComponent::HeaderLines, '-');
                table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
                table.set_header(vec!["KEY", "VALUE", "SECRET"]);

                for c in configs {
                    let secret = if c["is_secret"].as_bool() == Some(true) {
                        "yes"
                    } else {
                        "no"
                    };
                    table.add_row(vec![
                        c["key"].as_str().unwrap_or(""),
                        c["value"].as_str().unwrap_or(""),
                        secret,
                    ]);
                }

                println!("{table}");
            }
        }
    } else {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        eyre::bail!("Failed to list config ({}): {}", status, body);
    }

    Ok(())
}

/// Set a config value
pub async fn set(key: &str, value: &str, is_secret: bool, env: Option<&str>) -> eyre::Result<()> {
    let config = CliConfig::load_with_env(env)?;

    if let Some(ref name) = config.env_name {
        println!("Environment: {}", name);
    }

    let service_id = get_service_id(&config).await?;

    let payload = serde_json::json!({
        "key": key,
        "value": value,
        "is_secret": is_secret,
    });

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::PUT,
            &format!("{}/api/services/{}/config", config.api_url, service_id),
        )
        .json(&payload)
        .send()
        .await?;

    if resp.status().is_success() {
        println!("Config '{}' set successfully", key);
    } else {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        eyre::bail!("Failed to set config ({}): {}", status, body);
    }

    Ok(())
}

/// Delete a config value
pub async fn delete(key: &str, env: Option<&str>) -> eyre::Result<()> {
    let config = CliConfig::load_with_env(env)?;

    if let Some(ref name) = config.env_name {
        println!("Environment: {}", name);
    }

    let service_id = get_service_id(&config).await?;

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::DELETE,
            &format!(
                "{}/api/services/{}/config/{}",
                config.api_url, service_id, key
            ),
        )
        .send()
        .await?;

    if resp.status().is_success() {
        println!("Config '{}' deleted", key);
    } else {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        eyre::bail!("Failed to delete config ({}): {}", status, body);
    }

    Ok(())
}

/// Sync configs and secrets from Cufflink.toml to the platform
pub async fn sync(env: Option<&str>) -> eyre::Result<()> {
    let config = CliConfig::load_with_env(env)?;

    if let Some(ref name) = config.env_name {
        println!("Environment: {}", name);
    }

    let service_id = get_service_id(&config).await?;

    let project =
        ProjectConfig::find_and_load()?.ok_or_else(|| eyre::eyre!("No Cufflink.toml found"))?;

    let env_name = env
        .map(|s| s.to_string())
        .or_else(|| project.service.default_env.clone())
        .ok_or_else(|| eyre::eyre!("No environment specified"))?;

    let env_config = project.get_env(&env_name)?;

    sync_to_platform(
        &config,
        &service_id,
        &env_config.config,
        &env_config.secrets,
        &project,
        &env_name,
    )
    .await
}

/// Sync configs and secrets to the platform. Used by both `config sync` and `deploy`.
pub async fn sync_to_platform(
    cli_config: &CliConfig,
    service_id: &str,
    configs: &HashMap<String, String>,
    secrets: &HashMap<String, String>,
    project: &ProjectConfig,
    env_name: &str,
) -> eyre::Result<()> {
    let client = cli_config.http_client();
    let config_url = format!("{}/api/services/{}/config", cli_config.api_url, service_id);

    // Sync plaintext configs
    let mut synced = 0;
    for (key, value) in configs {
        let payload = serde_json::json!({
            "key": key,
            "value": value,
            "is_secret": false,
        });

        let resp = cli_config
            .auth_request(&client, reqwest::Method::PUT, &config_url)
            .json(&payload)
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            eyre::bail!("Failed to sync config '{}' ({}): {}", key, status, body);
        }
        synced += 1;
    }

    // Sync secrets from securestore
    let mut secrets_synced = 0;
    if !secrets.is_empty() {
        let store = super::secrets_cmd::load_store(project, env_name)?;

        for (config_key, store_name) in secrets {
            let value: String = store.get(store_name).map_err(|e| {
                eyre::eyre!(
                    "Secret '{}' not found in securestore for '{}': {}",
                    store_name,
                    env_name,
                    e
                )
            })?;

            let payload = serde_json::json!({
                "key": config_key,
                "value": value,
                "is_secret": true,
            });

            let resp = cli_config
                .auth_request(&client, reqwest::Method::PUT, &config_url)
                .json(&payload)
                .send()
                .await?;

            if !resp.status().is_success() {
                let status = resp.status();
                let body = resp.text().await.unwrap_or_default();
                eyre::bail!(
                    "Failed to sync secret '{}' ({}): {}",
                    config_key,
                    status,
                    body
                );
            }
            secrets_synced += 1;
        }
    }

    if synced > 0 || secrets_synced > 0 {
        println!(
            "  Synced {} config(s) and {} secret(s)",
            synced, secrets_synced
        );
    }

    Ok(())
}