esk 0.8.0

Encrypted Secrets Keeper with multi-target deploy
Documentation
use std::io::IsTerminal;

use anyhow::{bail, Result};
use console::style;

use crate::config::Config;
use crate::remotes;
use crate::store::SecretStore;
use crate::sync_tracker::SyncIndex;
use crate::targets::{CommandRunner, RealCommandRunner};
use crate::ui;

pub struct DeleteOptions<'a> {
    pub key: &'a str,
    pub env: &'a str,
    pub no_sync: bool,
    pub strict: bool,
}

struct DeleteReport {
    key: String,
    env: String,
    version: u64,
    push_results: Vec<super::sync::RemotePushResult>,
}

impl DeleteReport {
    fn remote_failure_count(&self) -> usize {
        self.push_results
            .iter()
            .filter(|r| r.outcome.is_err())
            .count()
    }

    fn render(&self) -> Result<()> {
        cliclack::log::success(format!(
            "Deleted {}:{} (v{})",
            self.key, self.env, self.version
        ))?;
        Ok(())
    }
}

pub fn run(config: &Config, opts: &DeleteOptions<'_>) -> Result<()> {
    let version = SecretStore::open(&config.root)?.payload()?.version;
    cliclack::intro(
        style(format!(
            "{} · {}",
            style(&config.project).bold(),
            style(format!("v{version}")).dim()
        ))
        .to_string(),
    )?;
    run_with_runner(config, opts, &RealCommandRunner)?;
    let payload = SecretStore::open(&config.root)?.payload()?;
    let env_versions: Vec<(String, u64)> = config
        .environments
        .iter()
        .map(|e| (e.clone(), payload.env_version(e)))
        .collect();
    cliclack::outro(
        style(ui::format_store_outro(
            payload.version,
            &env_versions,
            Some(opts.env),
        ))
        .dim()
        .to_string(),
    )?;
    Ok(())
}

pub fn run_with_runner(
    config: &Config,
    opts: &DeleteOptions<'_>,
    runner: &dyn CommandRunner,
) -> Result<()> {
    let key = opts.key;
    let env = opts.env;

    config.validate_env(env)?;

    if config.find_secret(key).is_none() {
        cliclack::log::warning(format!("Secret '{key}' is not defined in esk.yaml"))?;
    }

    // Warn if deleting a required secret
    if let Some((_, def)) = config.find_secret(key) {
        if def.required.is_required_in(env) && std::io::stdin().is_terminal() {
            let targets: Vec<String> = def
                .targets
                .keys()
                .map(std::string::ToString::to_string)
                .collect();
            let target_list = if targets.is_empty() {
                String::new()
            } else {
                format!(" (targets: {})", targets.join(", "))
            };
            let confirm = cliclack::confirm(format!(
                "{key}:{env} is required{target_list}. Delete anyway?",
            ))
            .initial_value(false)
            .interact()?;
            if !confirm {
                cliclack::log::info("Cancelled.")?;
                return Ok(());
            }
        }
    }

    let store = SecretStore::open(&config.root)?;
    let payload = store.delete(key, env)?;

    let mut push_results = Vec::new();
    if !opts.no_sync && !config.remotes.is_empty() {
        let sync_index_path = config.root.join(".esk/sync-index.json");
        let mut sync_index = SyncIndex::load(&sync_index_path);
        let all_remotes = remotes::build_remotes(config, runner);
        push_results =
            super::sync::push_to_remotes(&all_remotes, &payload, config, env, &mut sync_index)?;
        sync_index.save()?;
    }

    let report = DeleteReport {
        key: key.to_string(),
        env: env.to_string(),
        version: payload.version,
        push_results,
    };

    report.render()?;

    if opts.no_sync {
        return Ok(());
    }

    let remote_failures = report.remote_failure_count();
    if remote_failures > 0 && opts.strict {
        bail!(
            "{remote_failures} remote(s) failed to push (--strict). Target deploy skipped.\n\
             Fix the remote issue, then run:\n  \
             esk sync --env {env}\n  \
             esk deploy --env {env}"
        );
    }

    // Auto-deploy targets (env files regenerate without deleted key; individual targets delete)
    // strict: false — the user intentionally deleted this secret
    crate::cli::deploy::run_with_runner(
        config,
        &crate::cli::deploy::DeployOptions {
            env: Some(env),
            force: false,
            dry_run: false,
            verbose: false,
            skip_validation: false,
            strict: false,
            allow_empty: true,
            prune: false,
        },
        runner,
    )?;

    if remote_failures > 0 {
        bail!("{remote_failures} remote(s) failed to push. Run `esk sync --env {env}` to retry.");
    }

    Ok(())
}