esk 0.8.0

Encrypted Secrets Keeper with multi-target deploy
Documentation
//! Vercel target — deploys environment variables via the `vercel` CLI.
//!
//! Vercel is a cloud platform for frontend frameworks and serverless functions.
//! Environment variables are scoped to environments (production, preview,
//! development) and injected at build and runtime.
//!
//! CLI: `vercel` (Vercel's official CLI).
//! Commands: `vercel env add <key> <env> --force` / `vercel env rm <key> <env> --yes`.
//!
//! Secrets are sent via **stdin** to avoid process argument exposure. Vercel
//! uses its own environment names (production/preview/development), so esk
//! environment names are mapped via the `env_names` config field.

use anyhow::{Context, Result};

use crate::config::{Config, ResolvedTarget, VercelTargetConfig};
use crate::targets::{
    check_command, resolve_env_flags, CommandOpts, CommandRunner, DeployMode, DeployTarget,
};

pub struct VercelTarget<'a> {
    pub config: &'a Config,
    pub target_config: &'a VercelTargetConfig,
    pub runner: &'a dyn CommandRunner,
}

impl VercelTarget<'_> {
    fn resolve_env_name(&self, env: &str) -> Result<&str> {
        self.target_config
            .env_names
            .get(env)
            .map(std::string::String::as_str)
            .with_context(|| format!("no vercel env_names mapping for '{env}'"))
    }
}

impl DeployTarget for VercelTarget<'_> {
    fn name(&self) -> &'static str {
        "vercel"
    }

    fn deploy_mode(&self) -> DeployMode {
        DeployMode::Individual
    }

    fn preflight(&self) -> Result<()> {
        check_command(self.runner, "vercel").map_err(|_| {
            anyhow::anyhow!(
                "vercel is not installed or not in PATH. Install it with: npm install -g vercel"
            )
        })?;
        let output = self
            .runner
            .run("vercel", &["whoami"], CommandOpts::default())
            .context("failed to run vercel whoami")?;
        if !output.success {
            anyhow::bail!("vercel is not authenticated. Run: vercel login");
        }
        Ok(())
    }

    fn deploy_secret(&self, key: &str, value: &str, target: &ResolvedTarget) -> Result<()> {
        let vercel_env = self.resolve_env_name(&target.environment)?;

        let flag_parts = resolve_env_flags(&self.target_config.env_flags, &target.environment);
        let mut args: Vec<&str> = vec!["env", "add", key, vercel_env, "--force"];
        args.extend(flag_parts.iter().map(String::as_str));

        self.runner
            .run(
                "vercel",
                &args,
                CommandOpts {
                    stdin: Some(value.as_bytes().to_vec()),
                    ..Default::default()
                },
            )
            .with_context(|| format!("failed to run vercel env add for {key}"))?
            .check("vercel env add", key)
    }

    fn delete_secret(&self, key: &str, target: &ResolvedTarget) -> Result<()> {
        let vercel_env = self.resolve_env_name(&target.environment)?;

        let flag_parts = resolve_env_flags(&self.target_config.env_flags, &target.environment);
        let mut args: Vec<&str> = vec!["env", "rm", key, vercel_env, "--yes"];
        args.extend(flag_parts.iter().map(String::as_str));

        self.runner
            .run("vercel", &args, CommandOpts::default())
            .with_context(|| format!("failed to run vercel env rm for {key}"))?
            .check("vercel env rm", key)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::targets::CommandOutput;
    use crate::test_support::{ConfigFixture, ErrorCommandRunner, MockCommandRunner};

    fn make_config() -> ConfigFixture {
        let yaml = r#"
project: x
environments: [dev, prod]
targets:
  vercel:
    env_names:
      dev: development
      prod: production
    env_flags:
      prod: "--scope my-team"
"#;
        ConfigFixture::new(yaml).expect("fixture")
    }

    fn make_target(env: &str) -> ResolvedTarget {
        ResolvedTarget {
            service: "vercel".to_string(),
            app: None,
            environment: env.to_string(),
        }
    }

    #[test]
    fn vercel_preflight_success() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![
            CommandOutput {
                success: true,
                stdout: b"1.0.0".to_vec(),
                stderr: vec![],
            },
            CommandOutput {
                success: true,
                stdout: b"user".to_vec(),
                stderr: vec![],
            },
        ]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        assert!(target.preflight().is_ok());
    }

    #[test]
    fn vercel_preflight_auth_failure() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![
            CommandOutput {
                success: true,
                stdout: b"1.0.0".to_vec(),
                stderr: vec![],
            },
            CommandOutput {
                success: false,
                stdout: vec![],
                stderr: b"not logged in".to_vec(),
            },
        ]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target.preflight().unwrap_err();
        assert!(err.to_string().contains("vercel is not authenticated"));
    }

    #[test]
    fn vercel_preflight_missing_cli() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = ErrorCommandRunner::missing_command();
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target.preflight().unwrap_err();
        assert!(err.to_string().contains("vercel is not installed"));
    }

    #[test]
    fn vercel_deploy_correct_args() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: true,
            stdout: vec![],
            stderr: vec![],
        }]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        target
            .deploy_secret("MY_KEY", "secret_val", &make_target("dev"))
            .unwrap();
        let calls = runner.take_calls();
        assert_eq!(calls[0].program, "vercel");
        assert_eq!(
            calls[0].args,
            vec!["env", "add", "MY_KEY", "development", "--force"]
        );
    }

    #[test]
    fn vercel_passes_value_via_stdin() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: true,
            stdout: vec![],
            stderr: vec![],
        }]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        target
            .deploy_secret("KEY", "my_secret", &make_target("dev"))
            .unwrap();
        let calls = runner.take_calls();
        assert_eq!(calls[0].stdin.as_ref().unwrap(), b"my_secret");
    }

    #[test]
    fn vercel_deploy_with_env_flags() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: true,
            stdout: vec![],
            stderr: vec![],
        }]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        target
            .deploy_secret("KEY", "val", &make_target("prod"))
            .unwrap();
        let calls = runner.take_calls();
        assert_eq!(
            calls[0].args,
            vec![
                "env",
                "add",
                "KEY",
                "production",
                "--force",
                "--scope",
                "my-team"
            ]
        );
    }

    #[test]
    fn vercel_missing_env_mapping() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target
            .deploy_secret("KEY", "val", &make_target("staging"))
            .unwrap_err();
        assert!(err.to_string().contains("no vercel env_names mapping"));
    }

    #[test]
    fn vercel_delete_correct_args() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: true,
            stdout: vec![],
            stderr: vec![],
        }]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        target
            .delete_secret("MY_KEY", &make_target("prod"))
            .unwrap();
        let calls = runner.take_calls();
        assert_eq!(
            calls[0].args,
            vec![
                "env",
                "rm",
                "MY_KEY",
                "production",
                "--yes",
                "--scope",
                "my-team"
            ]
        );
    }

    #[test]
    fn vercel_delete_failure() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: false,
            stdout: vec![],
            stderr: b"not found".to_vec(),
        }]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target
            .delete_secret("KEY", &make_target("dev"))
            .unwrap_err();
        assert!(err.to_string().contains("not found"));
    }

    #[test]
    fn vercel_nonzero_exit() {
        let fixture = make_config();
        let config = fixture.config();
        let target_config = config.targets.vercel.as_ref().unwrap();
        let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
            success: false,
            stdout: vec![],
            stderr: b"auth error".to_vec(),
        }]);
        let target = VercelTarget {
            config,
            target_config,
            runner: &runner,
        };
        let err = target
            .deploy_secret("KEY", "val", &make_target("dev"))
            .unwrap_err();
        assert!(err.to_string().contains("auth error"));
    }
}