cufflink-cli 0.15.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::credentials;
use crate::project_config::ProjectConfig;

/// CLI configuration loaded from Cufflink.toml
pub struct CliConfig {
    pub api_url: String,
    pub tenant_slug: String,
    pub api_key: Option<String>,
    pub env_name: Option<String>,
    pub keycloak_url: Option<String>,
    pub keycloak_realm: Option<String>,
    pub keycloak_client_id: Option<String>,
    pub tenant_override: Option<String>,
}

impl CliConfig {
    /// Load config with optional environment override from Cufflink.toml.
    pub fn load_with_env(env: Option<&str>) -> eyre::Result<Self> {
        let project = ProjectConfig::find_and_load()?;

        if let Some(ref project) = project {
            if let Some(env_config) = project.resolve_env(env)? {
                let env_name = env
                    .map(|s| s.to_string())
                    .or_else(|| project.service.default_env.clone());

                let api_key = env_config
                    .api_key
                    .clone()
                    .or_else(|| {
                        env_config
                            .api_key_env
                            .as_ref()
                            .and_then(|var| std::env::var(var).ok())
                    })
                    .or_else(|| std::env::var("CUFFLINK_API_KEY").ok())
                    .or_else(|| {
                        credentials::load_for(&env_config.api_url, &env_config.tenant)
                            .ok()
                            .flatten()
                            .map(|c| c.api_key)
                    });

                return Ok(Self {
                    api_url: env_config.api_url.clone(),
                    tenant_slug: env_config.tenant.clone(),
                    api_key,
                    env_name,
                    keycloak_url: env_config
                        .keycloak_url
                        .clone()
                        .or_else(|| std::env::var("CUFFLINK_KEYCLOAK_URL").ok()),
                    keycloak_realm: env_config
                        .keycloak_realm
                        .clone()
                        .or_else(|| std::env::var("CUFFLINK_KEYCLOAK_REALM").ok()),
                    keycloak_client_id: env_config
                        .keycloak_client_id
                        .clone()
                        .or_else(|| std::env::var("CUFFLINK_CLIENT_ID").ok()),
                    tenant_override: None,
                });
            }
        }

        eyre::bail!(
            "No Cufflink.toml found. Run from a cufflink project directory or use `cufflink init` to create one."
        )
    }

    /// Load config for platform-admin commands (e.g., tenants).
    /// Uses Cufflink.toml if present, otherwise falls back to defaults.
    /// An explicit `api_url` override takes highest priority.
    pub fn for_platform(api_url_override: Option<&str>, env: Option<&str>) -> eyre::Result<Self> {
        // Try Cufflink.toml if available (best-effort)
        if let Ok(Some(project)) = ProjectConfig::find_and_load() {
            if let Ok(Some(env_config)) = project.resolve_env(env) {
                let api_url = api_url_override
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| env_config.api_url.clone());
                let env_name = env
                    .map(|s| s.to_string())
                    .or_else(|| project.service.default_env.clone());

                return Ok(Self {
                    api_url,
                    tenant_slug: env_config.tenant.clone(),
                    api_key: None,
                    env_name,
                    keycloak_url: env_config
                        .keycloak_url
                        .clone()
                        .or_else(|| std::env::var("CUFFLINK_KEYCLOAK_URL").ok()),
                    keycloak_realm: env_config
                        .keycloak_realm
                        .clone()
                        .or_else(|| std::env::var("CUFFLINK_KEYCLOAK_REALM").ok()),
                    keycloak_client_id: env_config
                        .keycloak_client_id
                        .clone()
                        .or_else(|| std::env::var("CUFFLINK_CLIENT_ID").ok()),
                    tenant_override: None,
                });
            }
        }

        // No Cufflink.toml — use defaults + overrides + env vars
        let api_url = api_url_override
            .map(|s| s.to_string())
            .or_else(|| std::env::var("CUFFLINK_API_URL").ok())
            .unwrap_or_else(|| "http://localhost:8080".to_string());

        Ok(Self {
            api_url,
            tenant_slug: String::new(),
            api_key: None,
            env_name: None,
            keycloak_url: std::env::var("CUFFLINK_KEYCLOAK_URL").ok(),
            keycloak_realm: std::env::var("CUFFLINK_KEYCLOAK_REALM").ok(),
            keycloak_client_id: std::env::var("CUFFLINK_CLIENT_ID").ok(),
            tenant_override: None,
        })
    }

    /// Create an HTTP client
    pub fn http_client(&self) -> reqwest::Client {
        reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(120))
            .build()
            .expect("Failed to build HTTP client")
    }

    pub async fn find_service_id(&self, service_name: &str) -> eyre::Result<String> {
        let client = self.http_client();
        let resp = self
            .auth_request(
                &client,
                reqwest::Method::GET,
                &format!("{}/api/services", self.api_url),
            )
            .send()
            .await?;

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

    /// Build a request with auth headers attached.
    /// Exits with a helpful error if no API key is configured.
    pub fn auth_request(
        &self,
        client: &reqwest::Client,
        method: reqwest::Method,
        url: &str,
    ) -> reqwest::RequestBuilder {
        if let Some(ref tenant) = self.tenant_override {
            if let Ok(platform_key) = std::env::var("CUFFLINK_PLATFORM_API_KEY") {
                return client
                    .request(method, url)
                    .header("Authorization", format!("ApiKey {}", platform_key))
                    .header("X-Tenant-Slug", tenant.as_str());
            }
        }

        let key = match &self.api_key {
            Some(k) => k,
            None => {
                eprintln!(
                    "Error: No credentials found for tenant '{}'.",
                    self.tenant_slug
                );
                eprintln!();
                eprintln!("  Run `cufflink login` to authenticate with your tenant.");
                eprintln!("  Or set CUFFLINK_API_KEY in your environment.");
                std::process::exit(1);
            }
        };
        client
            .request(method, url)
            .header("Authorization", format!("ApiKey {}", key))
    }
}