cufflink-cli 0.10.1

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::project_config::ProjectConfig;
use securestore::{KeySource, SecretsManager};
use std::path::PathBuf;

/// Resolve the secrets directory relative to the Cufflink.toml project directory.
fn secrets_dir(project: &ProjectConfig) -> PathBuf {
    project.project_dir.join("secrets")
}

/// Resolve the securestore vault path for a given environment.
fn vault_path(project: &ProjectConfig, env_name: &str) -> PathBuf {
    secrets_dir(project).join(format!("{}.json", env_name))
}

/// Resolve the key file path for a given environment.
fn key_path(project: &ProjectConfig, env_name: &str) -> PathBuf {
    secrets_dir(project).join(format!("{}.key", env_name))
}

/// Load the securestore for a given environment.
/// Key lookup: CUFFLINK_SECRETS_KEY env var (base64) → secrets/{env}.key file
pub fn load_store(project: &ProjectConfig, env_name: &str) -> eyre::Result<SecretsManager> {
    let vault = vault_path(project, env_name);
    if !vault.exists() {
        eyre::bail!(
            "Secrets vault not found at {}. Run: cufflink secrets init --env {}",
            vault.display(),
            env_name
        );
    }

    // Try env var first (for CI), then key file
    if let Ok(key_b64) = std::env::var("CUFFLINK_SECRETS_KEY") {
        let key_bytes =
            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key_b64.trim())
                .map_err(|e| eyre::eyre!("Failed to decode CUFFLINK_SECRETS_KEY: {}", e))?;

        // Write to temp file since securestore only accepts file paths
        let tmp = tempfile::NamedTempFile::new()?;
        std::fs::write(tmp.path(), &key_bytes)?;
        let store = SecretsManager::load(vault, KeySource::Path(tmp.path()))
            .map_err(|e| eyre::eyre!("Failed to load secrets vault: {}", e))?;
        return Ok(store);
    }

    let key = key_path(project, env_name);
    if !key.exists() {
        eyre::bail!(
            "Secrets key not found. Either:\n  \
             - Set CUFFLINK_SECRETS_KEY env var (base64-encoded)\n  \
             - Place key file at {}",
            key.display()
        );
    }

    let store = SecretsManager::load(vault, KeySource::Path(&key))
        .map_err(|e| eyre::eyre!("Failed to load secrets vault: {}", e))?;
    Ok(store)
}

/// Initialize a new securestore vault for an environment.
pub fn init(env: Option<&str>) -> eyre::Result<()> {
    let project =
        ProjectConfig::find_and_load()?.ok_or_else(|| eyre::eyre!("No Cufflink.toml found"))?;

    let env_name = resolve_env_name(&project, env)?;

    let dir = secrets_dir(&project);
    std::fs::create_dir_all(&dir)?;

    let vault = vault_path(&project, &env_name);
    let key = key_path(&project, &env_name);

    if vault.exists() {
        eyre::bail!("Secrets vault already exists at {}", vault.display());
    }

    // Create new vault with random key
    let store = SecretsManager::new(KeySource::Csprng)
        .map_err(|e| eyre::eyre!("Failed to create secrets vault: {}", e))?;
    store
        .export_key(&key)
        .map_err(|e| eyre::eyre!("Failed to export key: {}", e))?;
    store
        .save_as(&vault)
        .map_err(|e| eyre::eyre!("Failed to save vault: {}", e))?;

    // Create .gitignore for key files
    let gitignore = dir.join(".gitignore");
    if !gitignore.exists() {
        std::fs::write(&gitignore, "*.key\n")?;
    }

    println!("Secrets initialized for '{}':", env_name);
    println!("  Vault:  {}", vault.display());
    println!("  Key:    {}", key.display());
    println!("  .gitignore created (key files excluded)");
    println!();
    println!("Add secrets with:");
    println!(
        "  cufflink secrets set \"name\" \"value\" --env {}",
        env_name
    );

    Ok(())
}

/// Set a secret in the securestore vault.
pub fn set(name: &str, value: &str, env: Option<&str>) -> eyre::Result<()> {
    let project =
        ProjectConfig::find_and_load()?.ok_or_else(|| eyre::eyre!("No Cufflink.toml found"))?;

    let env_name = resolve_env_name(&project, env)?;
    let mut store = load_store(&project, &env_name)?;

    store.set(name, value);
    store
        .save()
        .map_err(|e| eyre::eyre!("Failed to save vault: {}", e))?;

    println!("Secret '{}' set for '{}'", name, env_name);
    Ok(())
}

/// Delete a secret from the securestore vault.
pub fn delete(name: &str, env: Option<&str>) -> eyre::Result<()> {
    let project =
        ProjectConfig::find_and_load()?.ok_or_else(|| eyre::eyre!("No Cufflink.toml found"))?;

    let env_name = resolve_env_name(&project, env)?;
    let mut store = load_store(&project, &env_name)?;

    store
        .remove(name)
        .map_err(|e| eyre::eyre!("Secret '{}' not found: {}", name, e))?;
    store
        .save()
        .map_err(|e| eyre::eyre!("Failed to save vault: {}", e))?;

    println!("Secret '{}' deleted from '{}'", name, env_name);
    Ok(())
}

/// List secret names in the securestore vault.
pub fn list(env: Option<&str>, reveal: bool) -> eyre::Result<()> {
    let project =
        ProjectConfig::find_and_load()?.ok_or_else(|| eyre::eyre!("No Cufflink.toml found"))?;

    let env_name = resolve_env_name(&project, env)?;
    let store = load_store(&project, &env_name)?;

    let names: Vec<&str> = store.keys().collect();
    if names.is_empty() {
        println!("No secrets stored for '{}'.", env_name);
    } else {
        println!("Secrets for '{}':", env_name);
        for name in names {
            if reveal {
                let value: String = store
                    .get(name)
                    .map_err(|e| eyre::eyre!("Failed to read '{}': {}", name, e))?;
                println!("  {} = {}", name, value);
            } else {
                println!("  {}", name);
            }
        }
    }

    Ok(())
}

fn resolve_env_name(project: &ProjectConfig, env: Option<&str>) -> eyre::Result<String> {
    let name = env
        .map(|s| s.to_string())
        .or_else(|| project.service.default_env.clone())
        .ok_or_else(|| {
            eyre::eyre!(
                "No environment specified. Use --env <name> or set default_env in Cufflink.toml"
            )
        })?;
    Ok(name)
}