use crate::provider::{Provider, providers};
use crate::{Config, GlobalConfig, GlobalDefaults, Profile, Project, Secrets};
use clap::{Parser, Subcommand};
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use std::collections::HashMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "secretspec")]
#[command(about = "Declarative secrets, every environment, any provider - https://secretspec.dev", long_about = None)]
#[command(version)]
struct Cli {
#[arg(short = 'f', long, global = true, env = "SECRETSPEC_FILE")]
file: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(short, long, default_value = "dotenv://.env")]
from: String,
},
Set {
name: String,
value: Option<String>,
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
},
Get {
name: String,
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
},
Run {
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
#[arg(trailing_var_arg = true)]
command: Vec<String>,
},
Check {
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
#[arg(short = 'n', long)]
no_prompt: bool,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
Import {
from_provider: String,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Init,
Show,
#[command(subcommand)]
Provider(ProviderAction),
}
#[derive(Subcommand)]
enum ProviderAction {
Add {
name: String,
uri: String,
},
Remove {
name: String,
},
List,
}
fn get_example_toml() -> &'static str {
r#"# DATABASE_URL = { description = "Database connection string", required = true }
[profiles.development]
# Development profile inherits all secrets from default profile
# Only define secrets here that need different values or settings than default
# DATABASE_URL = { default = "sqlite:///dev.db" }
#
# New secrets
# REDIS_URL = { description = "Redis connection URL for caching", required = false, default = "redis://localhost:6379" }
"#
}
fn generate_toml_with_comments(config: &Config) -> crate::Result<String> {
let mut output = String::new();
output.push_str("[project]\n");
output.push_str(&format!("name = \"{}\"\n", config.project.name));
output.push_str(&format!("revision = \"{}\"\n", config.project.revision));
output.push_str("# Extend configurations from subdirectories\n");
output.push_str("# extends = [ \"subdir1\", \"subdir2\" ]\n");
for (profile_name, profile_config) in &config.profiles {
output.push_str(&format!("\n[profiles.{}]\n", profile_name));
for (secret_name, secret_config) in &profile_config.secrets {
output.push_str(&format!(
"{} = {{ description = \"{}\"",
secret_name,
secret_config.description.as_deref().unwrap_or(""),
));
if let Some(required) = secret_config.required {
output.push_str(&format!(", required = {}", required));
}
if let Some(default) = &secret_config.default {
output.push_str(&format!(", default = \"{}\"", default));
}
output.push_str(" }\n");
}
}
Ok(output)
}
fn load_secrets(file: &Option<PathBuf>) -> miette::Result<Secrets> {
match file {
Some(path) => Secrets::load_from(path),
None => Secrets::load(),
}
.into_diagnostic()
.wrap_err("Failed to load secretspec configuration")
}
#[doc(hidden)]
pub fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { from } => {
if PathBuf::from("secretspec.toml").exists() {
use inquire::Confirm;
let overwrite = Confirm::new("secretspec.toml already exists. Overwrite?")
.with_default(false)
.prompt()
.into_diagnostic()?;
if !overwrite {
println!("Cancelled.");
return Ok(());
}
}
let provider: Box<dyn Provider> = from.as_str().try_into().into_diagnostic()?;
if provider.name() != "dotenv" {
return Err(miette!(
"Only 'dotenv' provider is currently supported for init --from. Got provider: {}",
provider.name()
));
}
let secrets = provider.reflect().into_diagnostic()?;
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let project_config = Config {
project: Project {
name: std::env::current_dir()
.into_diagnostic()?
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
revision: "1.0".to_string(),
extends: None,
},
profiles,
};
let mut content = generate_toml_with_comments(&project_config).into_diagnostic()?;
content.push_str(get_example_toml());
fs::write("secretspec.toml", content).into_diagnostic()?;
#[cfg(unix)]
{
let metadata = fs::metadata("secretspec.toml").into_diagnostic()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600);
fs::set_permissions("secretspec.toml", permissions).into_diagnostic()?;
}
let secret_count = project_config
.profiles
.values()
.map(|p| p.secrets.len())
.sum::<usize>();
println!("✓ Created secretspec.toml with {} secrets", secret_count);
if provider.name() == "dotenv" && secret_count > 0 {
println!("\nTo migrate your secrets from {}:", from);
println!(" 1. Review secretspec.toml and adjust as needed");
println!(" 2. secretspec import {} # Import secret values", from);
}
println!("\nNext steps:");
println!(" 1. secretspec config init # Set up user configuration");
println!(" 2. secretspec check # Verify all secrets and set them");
println!(" 3. secretspec run -- your-command # Run with secrets");
Ok(())
}
Commands::Config { action } => match action {
ConfigAction::Init => {
use inquire::Select;
let provider_choices: Vec<String> = providers()
.into_iter()
.map(|info| info.display_with_examples())
.collect();
let selected_choice =
Select::new("Select your preferred provider backend:", provider_choices)
.prompt()
.into_diagnostic()?;
let provider = selected_choice.split(':').next().unwrap_or("keyring");
let profiles = vec!["development", "default", "none"];
let profile_choice = Select::new("Select your default profile:", profiles)
.with_help_message(
"'development' is recommended for local development environments",
)
.prompt()
.into_diagnostic()?;
let profile = if profile_choice == "none" {
None
} else {
Some(profile_choice.to_string())
};
let config = GlobalConfig {
defaults: GlobalDefaults {
provider: Some(provider.to_string()),
profile,
providers: None,
},
};
config.save().into_diagnostic()?;
println!(
"\n✓ Configuration saved to {}",
GlobalConfig::path().into_diagnostic()?.display()
);
Ok(())
}
ConfigAction::Show => {
match GlobalConfig::load().into_diagnostic()? {
Some(config) => {
println!(
"Configuration file: {}\n",
GlobalConfig::path().into_diagnostic()?.display()
);
match config.defaults.provider {
Some(provider) => println!("Provider: {}", provider),
None => println!("Provider: (none)"),
}
match config.defaults.profile {
Some(profile) => println!("Profile: {}", profile),
None => println!("Profile: (none)"),
}
if let Some(providers) = &config.defaults.providers {
println!("\nProvider Aliases:");
let mut aliases: Vec<_> = providers.iter().collect();
aliases.sort_by(|(a, _), (b, _)| a.cmp(b));
for (alias, uri) in aliases {
println!(" {} = {}", alias, uri);
}
} else {
println!("\nProvider Aliases: (none)");
}
}
None => {
println!(
"No configuration found. Run 'secretspec config init' to create one."
);
}
}
Ok(())
}
ConfigAction::Provider(action) => {
match action {
ProviderAction::Add { name, uri } => {
let mut config =
GlobalConfig::load()
.into_diagnostic()?
.unwrap_or(GlobalConfig {
defaults: GlobalDefaults {
provider: None,
profile: None,
providers: None,
},
});
if config.defaults.providers.is_none() {
config.defaults.providers = Some(HashMap::new());
}
if let Some(providers) = &mut config.defaults.providers {
let existing = providers.insert(name.clone(), uri.clone());
config.save().into_diagnostic()?;
if existing.is_some() {
println!("✓ Provider alias '{}' updated to '{}'", name, uri);
} else {
println!("✓ Provider alias '{}' added: '{}'", name, uri);
}
}
Ok(())
}
ProviderAction::Remove { name } => {
match GlobalConfig::load().into_diagnostic()? {
Some(mut config) => {
if let Some(providers) = &mut config.defaults.providers {
if providers.remove(&name).is_some() {
config.save().into_diagnostic()?;
println!("✓ Provider alias '{}' removed", name);
} else {
println!("✗ Provider alias '{}' not found", name);
}
} else {
println!("✗ No provider aliases configured");
}
}
None => {
println!(
"✗ No configuration found. Run 'secretspec config init' first."
);
}
}
Ok(())
}
ProviderAction::List => {
match GlobalConfig::load().into_diagnostic()? {
Some(config) => {
if let Some(providers) = config.defaults.providers {
if providers.is_empty() {
println!("No provider aliases configured.");
} else {
println!("Provider Aliases:");
let mut aliases: Vec<_> = providers.into_iter().collect();
aliases.sort_by(|(a, _), (b, _)| a.cmp(b));
for (alias, uri) in aliases {
println!(" {} = {}", alias, uri);
}
}
} else {
println!("No provider aliases configured.");
}
}
None => {
println!(
"No configuration found. Run 'secretspec config init' first."
);
}
}
Ok(())
}
}
}
},
Commands::Set {
name,
value,
provider,
profile,
} => {
let mut app = load_secrets(&cli.file)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
app.set(&name, value)
.into_diagnostic()
.wrap_err("Failed to set secret")?;
Ok(())
}
Commands::Get {
name,
provider,
profile,
} => {
let mut app = load_secrets(&cli.file)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
app.get(&name)
.into_diagnostic()
.wrap_err("Failed to get secret")?;
Ok(())
}
Commands::Run {
command,
provider,
profile,
} => {
let mut app = load_secrets(&cli.file)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
app.run(command)
.into_diagnostic()
.wrap_err("Failed to run command")?;
Ok(())
}
Commands::Check {
provider,
profile,
no_prompt,
} => {
let mut app = load_secrets(&cli.file)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
let mut validated = app
.check(no_prompt)
.into_diagnostic()
.wrap_err("Failed to check secrets")?;
validated
.keep_temp_files()
.into_diagnostic()
.wrap_err("Failed to persist temporary files")?;
Ok(())
}
Commands::Import { from_provider } => {
let app = load_secrets(&cli.file)?;
app.import(&from_provider)
.into_diagnostic()
.wrap_err("Failed to import secrets")?;
Ok(())
}
}
}