feat: created new first-time run wizard for secrets provider

This commit is contained in:
2026-06-03 08:08:06 -06:00
parent bba094086d
commit 8ad764527d
3 changed files with 202 additions and 4 deletions
+20 -3
View File
@@ -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");
+1
View File
@@ -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;
+181 -1
View File
@@ -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<Option<SupportedProvider>> {
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<SupportedProvider> {
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<SupportedProvider> {
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<SupportedProvider> {
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<SupportedProvider> {
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<SupportedProvider> {
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<String>)> {
let mut missing_secrets = vec![];
let mut fatal_error: Option<anyhow::Error> = None;