From 8ad764527d55c515266fab13e0a2e766c7c52d44 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 3 Jun 2026 08:08:06 -0600 Subject: [PATCH] feat: created new first-time run wizard for secrets provider --- src/config/mod.rs | 23 +++++- src/vault/mod.rs | 1 + src/vault/utils.rs | 182 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index ca01f65..c623479 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -50,7 +50,9 @@ use crate::utils::*; pub use macros::macro_execute; use crate::config::macros::Macro; -use crate::vault::{GlobalVault, Vault, create_vault_password_file, interpolate_secrets}; +use crate::vault::{ + GlobalVault, Vault, create_vault_password_file, interpolate_secrets, prompt_provider_choice, +}; use anyhow::{Context, Result, anyhow, bail}; use fancy_regex::Regex; use gman::providers::SupportedProvider; @@ -677,7 +679,13 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> { process::exit(0); } - let mut vault = Vault::init_bare(); + let provider_choice = prompt_provider_choice()?; + let mut vault = match &provider_choice { + None => Vault::init_bare(), + Some(provider) => Vault { + provider: provider.clone(), + }, + }; create_vault_password_file(&mut vault)?; let client = Select::new("API Provider (required):", list_client_types()).prompt()?; @@ -685,7 +693,16 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> { let mut config = json!({}); let (model, clients_config) = create_client_config(client, &vault).await?; config["model"] = model.into(); - config["vault_password_file"] = vault.local_password_file()?.display().to_string().into(); + match &provider_choice { + None => { + config["vault_password_file"] = + vault.local_password_file()?.display().to_string().into(); + } + Some(provider) => { + config["secrets_provider"] = serde_json::to_value(provider) + .with_context(|| "failed to serialize secrets_provider config")?; + } + } config["stream"] = json!(true); config["save"] = json!(true); config["keybindings"] = json!("vi"); diff --git a/src/vault/mod.rs b/src/vault/mod.rs index dc29134..272841e 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -3,6 +3,7 @@ mod utils; use std::path::PathBuf; pub use utils::create_vault_password_file; pub use utils::interpolate_secrets; +pub use utils::prompt_provider_choice; use crate::cli::Cli; use crate::config::AppConfig; diff --git a/src/vault/utils.rs b/src/vault/utils.rs index ad2fdf3..5e0e84d 100644 --- a/src/vault/utils.rs +++ b/src/vault/utils.rs @@ -3,11 +3,17 @@ use crate::vault::{SECRET_RE, Vault}; use anyhow::Result; use anyhow::anyhow; use gman::providers::SupportedProvider; +use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider; +use gman::providers::azure_key_vault::AzureKeyVaultProvider; +use gman::providers::gcp_secret_manager::GcpSecretManagerProvider; +use gman::providers::gopass::GopassProvider; use gman::providers::local::LocalProvider; +use gman::providers::one_password::OnePasswordProvider; use indoc::formatdoc; use inquire::validator::Validation; -use inquire::{Confirm, Password, PasswordDisplayMode, Text, min_length, required}; +use inquire::{Confirm, Password, PasswordDisplayMode, Select, Text, min_length, required}; use std::path::PathBuf; +use std::process::Command; use gman::SecretError; pub fn ensure_password_file_initialized(local_provider: &mut LocalProvider) -> Result<()> { @@ -173,6 +179,180 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> { Ok(()) } +pub fn prompt_provider_choice() -> Result> { + let choices = vec![ + "local - encrypted file on this machine", + "aws_secrets_manager - AWS Secrets Manager", + "gcp_secret_manager - Google Cloud Secret Manager", + "azure_key_vault - Azure Key Vault", + "gopass - gopass password manager (requires the `gopass` CLI)", + "one_password - 1Password (requires the `op` CLI)", + ]; + let choice = Select::new("Which secrets provider would you like to use?", choices) + .with_starting_cursor(0) + .prompt()?; + + if choice.starts_with("local") { + return Ok(None); + } + + let provider = if choice.starts_with("aws_secrets_manager") { + prompt_aws_provider()? + } else if choice.starts_with("gcp_secret_manager") { + prompt_gcp_provider()? + } else if choice.starts_with("azure_key_vault") { + prompt_azure_provider()? + } else if choice.starts_with("gopass") { + prompt_gopass_provider()? + } else if choice.starts_with("one_password") { + prompt_one_password_provider()? + } else { + return Err(anyhow!("unexpected provider choice: {choice}")); + }; + + Ok(Some(provider)) +} + +fn prompt_aws_provider() -> Result { + let aws_profile = Text::new("AWS profile name:") + .with_default("default") + .with_validator(required!()) + .with_help_message("From your ~/.aws/config and ~/.aws/credentials") + .prompt()?; + let aws_region = Text::new("AWS region:") + .with_default("us-east-1") + .with_validator(required!()) + .with_help_message("Where your secrets live (e.g. us-east-1, eu-west-2)") + .prompt()?; + + advisory_preflight( + "AWS", + "aws", + &["sts", "get-caller-identity", "--profile", &aws_profile], + ); + + Ok(SupportedProvider::AwsSecretsManager { + provider_def: AwsSecretsManagerProvider { + aws_profile: Some(aws_profile), + aws_region: Some(aws_region), + }, + }) +} + +fn prompt_gcp_provider() -> Result { + let gcp_project_id = Text::new("GCP project ID:") + .with_validator(required!()) + .with_help_message("The project that hosts your Secret Manager secrets") + .prompt()?; + + advisory_preflight( + "GCP", + "gcloud", + &["auth", "application-default", "print-access-token"], + ); + + Ok(SupportedProvider::GcpSecretManager { + provider_def: GcpSecretManagerProvider { + gcp_project_id: Some(gcp_project_id), + }, + }) +} + +fn prompt_azure_provider() -> Result { + let vault_name = Text::new("Azure Key Vault name:") + .with_validator(required!()) + .with_help_message("Just the vault name; the https endpoint is auto-derived") + .prompt()?; + + advisory_preflight("Azure", "az", &["account", "show"]); + + Ok(SupportedProvider::AzureKeyVault { + provider_def: AzureKeyVaultProvider { + vault_name: Some(vault_name), + }, + }) +} + +fn prompt_gopass_provider() -> Result { + let store_raw = Text::new("gopass store (leave blank for default):").prompt()?; + let store = match store_raw.trim() { + "" => None, + s => Some(s.to_string()), + }; + + required_cli_preflight("gopass", "gopass", "https://www.gopass.pw/"); + + Ok(SupportedProvider::Gopass { + provider_def: GopassProvider { store }, + }) +} + +fn prompt_one_password_provider() -> Result { + let vault_raw = Text::new("1Password vault (leave blank for default):").prompt()?; + let vault = match vault_raw.trim() { + "" => None, + s => Some(s.to_string()), + }; + + let account_raw = Text::new("1Password account (leave blank for default):").prompt()?; + let account = match account_raw.trim() { + "" => None, + s => Some(s.to_string()), + }; + + required_cli_preflight( + "1Password CLI", + "op", + "https://developer.1password.com/docs/cli/", + ); + + Ok(SupportedProvider::OnePassword { + provider_def: OnePasswordProvider { vault, account }, + }) +} + +fn advisory_preflight(label: &str, cli: &str, args: &[&str]) { + match Command::new(cli).args(args).output() { + Ok(out) if out.status.success() => { + println!("✓ {label} authentication check succeeded."); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + eprintln!("⚠️ {label} preflight returned non-zero:"); + if !stderr.trim().is_empty() { + eprintln!(" {}", stderr.trim()); + } + eprintln!( + " Setup will continue. Fix authentication before using --add-secret etc." + ); + } + Err(_) => { + eprintln!( + "⚠️ `{cli}` CLI not found on PATH. Coyote will still try the {label} SDK directly via standard credentials (env vars, instance metadata, service-account JSON, etc.)." + ); + } + } +} + +fn required_cli_preflight(label: &str, cli: &str, install_url: &str) { + match Command::new(cli).arg("--version").output() { + Ok(out) if out.status.success() => { + println!("✓ {label} is installed and reachable."); + } + Ok(_) => { + eprintln!( + "⚠️ `{cli} --version` returned non-zero. Your {label} install may be broken — verify before using the vault." + ); + } + Err(_) => { + eprintln!("⚠️ `{cli}` not found on PATH."); + eprintln!( + " The {label} secrets provider requires it. Install from {install_url} before running --add-secret etc." + ); + } + } +} + pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec)> { let mut missing_secrets = vec![]; let mut fatal_error: Option = None;