use std::collections::HashMap;
use std::io::Write;
use std::path::{
Path,
PathBuf,
};
use anyhow::{
Context,
Result,
};
use clap::{
Parser,
Subcommand,
ValueEnum,
};
use dotenvage::{
AutoDetectPatterns,
SecretManager,
};
#[derive(Debug, Clone, Copy)]
struct DumpOptions {
bash: bool,
make: bool,
make_eval: bool,
export: bool,
docker: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum KeyStore {
File,
Os,
Both,
System,
}
#[derive(Subcommand, Debug, Clone)]
enum Commands {
#[command(alias = "gen")]
Keygen {
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
force: bool,
#[arg(long, value_enum, default_value_t = KeyStore::File)]
store: KeyStore,
},
Encrypt {
#[arg(default_value = ".env.local")]
file: PathBuf,
#[arg(short, long, value_delimiter = ',')]
keys: Option<Vec<String>>,
#[arg(short, long, default_value = "true")]
auto: bool,
},
Edit {
#[arg(default_value = ".env.local")]
file: PathBuf,
},
Set {
pair: String,
#[arg(short, long, default_value = ".env.local")]
file: PathBuf,
},
Get {
key: String,
#[arg(short, long)]
file: Option<PathBuf>,
},
List {
#[arg(short, long)]
file: Option<PathBuf>,
#[arg(long)]
show_values: bool,
#[arg(short, long)]
plain: bool,
#[arg(short, long)]
json: bool,
},
Dump {
#[arg(short, long)]
file: Option<PathBuf>,
#[arg(short, long)]
bash: bool,
#[arg(short, long)]
make: bool,
#[arg(long)]
make_eval: bool,
#[arg(short, long)]
export: bool,
#[arg(short, long)]
docker: bool,
},
}
#[derive(Parser, Debug, Clone)]
#[command(name = "dotenvage", version, about = "Dotenv with age encryption")]
struct Cli {
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
fn parse_env_file(content: &str) -> Result<HashMap<String, String>> {
dotenvy::from_read_iter(content.as_bytes())
.collect::<Result<HashMap<String, String>, _>>()
.context("Failed to parse .env file")
}
fn write_env_file(path: &Path, vars: &HashMap<String, String>) -> Result<()> {
let mut file = std::fs::File::create(path)
.with_context(|| format!("Failed to create {}", path.display()))?;
let mut keys: Vec<_> = vars.keys().collect();
keys.sort();
for key in keys {
let value = vars.get(key).unwrap();
if value.contains(' ') || value.contains('$') || value.contains('\n') {
writeln!(file, "{}=\"{}\"", key, value.replace('"', "\\\""))?;
} else {
writeln!(file, "{}={}", key, value)?;
}
}
Ok(())
}
fn main() -> Result<()> {
let cli = <Cli as clap::Parser>::parse();
match cli.command {
Commands::Keygen {
output,
force,
store,
} => keygen(output, force, store),
Commands::Encrypt { file, keys, auto } => encrypt(file, keys, auto),
Commands::Edit { file } => edit(file),
Commands::Set { pair, file } => set(pair, file),
Commands::Get { key, file } => get(key, file, cli.verbose),
Commands::List {
file,
show_values,
plain,
json,
} => list(file, show_values, plain, json, cli.verbose),
Commands::Dump {
file,
bash,
make,
make_eval,
export,
docker,
} => {
let options = DumpOptions {
bash,
make,
make_eval,
export,
docker,
};
dump(file, options, cli.verbose)
}
}
}
fn keygen(output: Option<PathBuf>, force: bool, store: KeyStore) -> Result<()> {
use dotenvage::{
KeyGenOptions,
KeyLocation,
KeyStoreTarget,
};
if matches!(store, KeyStore::Os | KeyStore::System) && output.is_some() {
anyhow::bail!("--output is only valid when --store is 'file' or 'both'");
}
let target = match store {
KeyStore::File => KeyStoreTarget::File,
KeyStore::Os => KeyStoreTarget::OsKeychain,
KeyStore::Both => KeyStoreTarget::OsKeychainAndFile,
KeyStore::System => KeyStoreTarget::SystemStore,
};
let result = SecretManager::generate_and_save(KeyGenOptions {
target,
key_name: None,
file_path: output,
force,
})
.context("Failed to generate and save key")?;
for loc in &result.locations {
match loc {
KeyLocation::UserFile(p) => {
println!("Private key saved to: {}", p.display());
}
KeyLocation::OsKeychain { service, account } => {
println!(
"Private key saved to OS keychain \
(service: {}, account: {})",
service, account
);
}
KeyLocation::SystemKeychain { service, account } => {
println!(
"Private key saved to System Keychain \
(service: {}, account: {})",
service, account
);
}
KeyLocation::SystemFile(p) => {
println!("Private key saved to system store: {}", p.display());
}
}
}
println!("Public recipient: {}", result.public_key);
Ok(())
}
fn encrypt(file: PathBuf, keys: Option<Vec<String>>, auto: bool) -> Result<()> {
let manager = SecretManager::new().context("Failed to load encryption key")?;
if !file.exists() {
anyhow::bail!("File not found: {}", file.display());
}
let content = std::fs::read_to_string(&file)
.with_context(|| format!("Failed to read {}", file.display()))?;
let mut vars = parse_env_file(&content)?;
let mut encrypted_count = 0;
let keys_to_encrypt: Vec<String> = if let Some(specific) = keys {
specific
.into_iter()
.filter(|k| !AutoDetectPatterns::is_age_key_variable(k))
.collect()
} else if auto {
vars.keys()
.filter(|k| AutoDetectPatterns::should_encrypt(k))
.cloned()
.collect()
} else {
anyhow::bail!("Either --keys or --auto must be specified");
};
for key in &keys_to_encrypt {
if let Some(value) = vars.get(key)
&& !SecretManager::is_encrypted(value)
{
let encrypted = manager
.encrypt_value(value)
.with_context(|| format!("Failed to encrypt {}", key))?;
vars.insert(key.clone(), encrypted);
encrypted_count += 1;
}
}
write_env_file(&file, &vars)?;
println!(
"✓ Encrypted {} value(s) in {}",
encrypted_count,
file.display()
);
if encrypted_count > 0 {
println!(" Encrypted keys:");
for key in &keys_to_encrypt {
if vars
.get(key)
.is_some_and(|v| SecretManager::is_encrypted(v))
{
println!(" - {}", key);
}
}
}
Ok(())
}
fn edit(file: PathBuf) -> Result<()> {
let manager = SecretManager::new().context("Failed to load encryption key")?;
if !file.exists() {
anyhow::bail!("File not found: {}", file.display());
}
let content = std::fs::read_to_string(&file)
.with_context(|| format!("Failed to read {}", file.display()))?;
let mut vars = parse_env_file(&content)?;
let mut keys_to_encrypt = Vec::new();
for (key, value) in &mut vars {
if SecretManager::is_encrypted(value) {
if !AutoDetectPatterns::is_age_key_variable(key) {
keys_to_encrypt.push(key.clone());
}
*value = manager
.decrypt_value(value)
.with_context(|| format!("Failed to decrypt {}", key))?;
}
}
let temp = tempfile::Builder::new()
.suffix(".env")
.tempfile()
.context("Failed to create temp file")?;
write_env_file(temp.path(), &vars)?;
let original = std::fs::read_to_string(temp.path()).context("Failed to read temp file")?;
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let status = std::process::Command::new(&editor)
.arg(temp.path())
.status()
.with_context(|| format!("Failed to launch editor: {}", editor))?;
if !status.success() {
anyhow::bail!("Editor exited with non-zero status");
}
let edited = std::fs::read_to_string(temp.path()).context("Failed to read edited file")?;
if edited == original {
println!("No changes made.");
return Ok(());
}
let mut edited_vars = parse_env_file(&edited)?;
for key in &keys_to_encrypt {
if AutoDetectPatterns::is_age_key_variable(key) {
continue;
}
if let Some(value) = edited_vars.get_mut(key)
&& !SecretManager::is_encrypted(value)
{
*value = manager
.encrypt_value(value)
.with_context(|| format!("Failed to encrypt {}", key))?;
}
}
write_env_file(&file, &edited_vars)?;
println!("✓ Saved encrypted changes to {}", file.display());
Ok(())
}
fn set(pair: String, file: PathBuf) -> Result<()> {
let manager = SecretManager::new().context("Failed to load encryption key")?;
let (key, value) = pair.split_once('=').context("Invalid KEY=VALUE format")?;
let loader = dotenvage::EnvLoader::with_manager(manager);
loader
.set_var_in_file(key, value, &file)
.with_context(|| format!("Failed to write {}", file.display()))?;
let final_value = std::fs::read_to_string(&file)
.with_context(|| format!("Failed to read {}", file.display()))
.and_then(|content| {
parse_env_file(&content)?
.get(key)
.cloned()
.context("Key not found after write")
})?;
let status = if SecretManager::is_encrypted(&final_value) {
"encrypted"
} else {
"plain"
};
println!("✓ Set {} ({}) in {}", key, status, file.display());
Ok(())
}
fn get(key: String, file: Option<PathBuf>, verbose_files: bool) -> Result<()> {
let manager = SecretManager::new().context("Failed to load encryption key")?;
let value = if let Some(file_path) = file {
if verbose_files {
eprintln!("Reading: {}", file_path.display());
}
let content = std::fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
let vars = parse_env_file(&content)?;
vars.get(&key)
.with_context(|| format!("Key '{}' not found in {}", key, file_path.display()))?
.clone()
} else {
let loader = dotenvage::EnvLoader::with_manager(manager.clone());
let (vars, paths) = loader
.collect_all_vars_from_dir(Path::new("."))
.context("Failed to collect environment variables")?;
if verbose_files {
for p in &paths {
eprintln!("Reading: {}", p.display());
}
}
vars.get(&key)
.with_context(|| format!("Key '{}' not found in any .env* file", key))?
.clone()
};
let decrypted = manager
.decrypt_value(&value)
.context("Failed to decrypt value")?;
println!("{}", decrypted);
Ok(())
}
fn list(
file: Option<PathBuf>,
show_values: bool,
plain: bool,
json: bool,
verbose_files: bool,
) -> Result<()> {
let manager = SecretManager::new().context("Failed to load encryption key")?;
let vars = if let Some(file_path) = file {
if !file_path.exists() {
anyhow::bail!("File not found: {}", file_path.display());
}
if verbose_files {
eprintln!("Reading: {}", file_path.display());
}
let content = std::fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
parse_env_file(&content)?
} else {
let loader = dotenvage::EnvLoader::with_manager(manager.clone());
let (all_vars, paths) = loader
.collect_all_vars_from_dir(Path::new("."))
.context("Failed to collect environment variables")?;
if verbose_files {
for path in &paths {
eprintln!("Reading: {}", path.display());
}
}
all_vars
};
let mut keys: Vec<_> = vars.keys().collect();
keys.sort();
if json {
let mut output = HashMap::new();
for key in keys {
if is_age_key_variable(key) {
continue;
}
let value = vars.get(key).unwrap();
let is_encrypted = SecretManager::is_encrypted(value);
let mut entry = HashMap::new();
entry.insert("encrypted", is_encrypted.to_string());
if show_values {
let display_value = if is_encrypted {
manager
.decrypt_value(value)
.unwrap_or_else(|_| "<decryption failed>".to_string())
} else {
value.clone()
};
entry.insert("value", display_value);
}
output.insert(key, entry);
}
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
for key in keys {
if is_age_key_variable(key) {
continue;
}
let value = vars.get(key).unwrap();
let is_encrypted = SecretManager::is_encrypted(value);
print_list_entry(&manager, key, value, is_encrypted, show_values, plain)?;
}
}
Ok(())
}
fn print_list_entry(
manager: &SecretManager,
key: &str,
value: &str,
is_encrypted: bool,
verbose: bool,
plain: bool,
) -> Result<()> {
let lock_icon = if is_encrypted { "🔒" } else { " " };
match (verbose, plain) {
(true, true) => {
let display_value = if is_encrypted {
manager
.decrypt_value(value)
.unwrap_or_else(|_| "<decryption failed>".to_string())
} else {
value.to_string()
};
println!("{} = {}", key, display_value);
}
(true, false) => {
let display_value = if is_encrypted {
manager
.decrypt_value(value)
.unwrap_or_else(|_| "<decryption failed>".to_string())
} else {
value.to_string()
};
println!("{} {} = {}", lock_icon, key, display_value);
}
(false, true) => {
println!("{}", key);
}
(false, false) => {
println!("{} {}", lock_icon, key);
}
}
Ok(())
}
fn dump(file: Option<PathBuf>, options: DumpOptions, verbose_files: bool) -> Result<()> {
let manager = SecretManager::new().context("Failed to load encryption key")?;
if let Some(file_path) = file {
if !file_path.exists() {
anyhow::bail!("File not found: {}", file_path.display());
}
if verbose_files {
eprintln!("Reading: {}", file_path.display());
}
let content = std::fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
let all_vars = parse_env_file(&content)?;
dump_vars(&manager, &all_vars, options)?;
} else {
let loader = dotenvage::EnvLoader::with_manager(manager.clone());
let (merged_vars, paths) = loader
.collect_all_vars_from_dir(Path::new("."))
.context("Failed to collect environment variables")?;
if verbose_files {
for path in &paths {
eprintln!("Reading: {}", path.display());
}
}
dump_vars(&manager, &merged_vars, options)?;
}
Ok(())
}
fn dump_vars(
manager: &SecretManager,
vars: &HashMap<String, String>,
options: DumpOptions,
) -> Result<()> {
let mut keys: Vec<_> = vars.keys().cloned().collect();
keys.sort();
for key in keys {
if is_age_key_variable(&key) {
continue;
}
if let Some(value) = vars.get(&key) {
let decrypted_value = manager
.decrypt_value(value)
.with_context(|| format!("Failed to decrypt {}", key))?;
dump_single_var(&key, &decrypted_value, options);
}
}
Ok(())
}
fn is_age_key_variable(key: &str) -> bool {
let key_upper = key.to_uppercase();
matches!(
key_upper.as_str(),
"DOTENVAGE_AGE_KEY" | "AGE_KEY" | "EKG_AGE_KEY" | "AGE_KEY_NAME"
) || key_upper.ends_with("_AGE_KEY_NAME")
}
fn dump_single_var(key: &str, value: &str, options: DumpOptions) {
if options.make_eval {
dump_make_eval_var(key, value, options.export);
} else if options.make {
dump_make_var(key, value, options.export);
} else {
dump_env_var(key, value, options);
}
}
fn dump_make_eval_var(key: &str, value: &str, export: bool) {
let prefix = if export { "export " } else { "" };
let escaped_value = escape_for_make_eval(value);
println!("$(eval {}{} := {})", prefix, key, escaped_value);
}
fn dump_make_var(key: &str, value: &str, export: bool) {
let prefix = if export { "export " } else { "" };
let escaped_value = escape_for_make(value);
println!("{}{} := {}", prefix, key, escaped_value);
}
fn dump_env_var(key: &str, value: &str, options: DumpOptions) {
let prefix = if options.export { "export " } else { "" };
if options.docker {
dump_docker_var(key, value, prefix);
} else if options.bash || options.export {
dump_bash_var(key, value, prefix);
} else {
dump_simple_var(key, value, prefix);
}
}
fn dump_docker_var(key: &str, value: &str, prefix: &str) {
println!("{}{}={}", prefix, key, value);
}
fn dump_bash_var(key: &str, value: &str, prefix: &str) {
if needs_bash_quoting(value) {
println!(
"{}{}=\"{}\"",
prefix,
key,
escape_for_bash_double_quotes(value)
);
} else {
println!("{}{}={}", prefix, key, value);
}
}
fn dump_simple_var(key: &str, value: &str, prefix: &str) {
if needs_simple_quoting(value) {
println!("{}{}=\"{}\"", prefix, key, escape_for_simple_quotes(value));
} else {
println!("{}{}={}", prefix, key, value);
}
}
fn needs_simple_quoting(value: &str) -> bool {
if value.is_empty() {
return true;
}
value.contains(char::is_whitespace)
|| value.contains('=')
|| value.contains('"')
|| value.contains('\'')
}
fn escape_for_simple_quotes(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn needs_bash_quoting(value: &str) -> bool {
if value.is_empty() {
return true;
}
const SPECIAL_CHARS: &[char] = &[
' ', '\t', '\n', '\r', '$', '`', '\\', '"', '\'', '&', '|', ';', '<', '>', '(', ')', '{', '}', '[', ']', '*', '?', '!', '~', '#', '=', ];
value.chars().any(|c| SPECIAL_CHARS.contains(&c))
}
fn escape_for_bash_double_quotes(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\\' => result.push_str("\\\\"),
'"' => result.push_str("\\\""),
'$' => result.push_str("\\$"),
'`' => result.push_str("\\`"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
'!' => result.push_str("\\!"),
_ => result.push(c),
}
}
result
}
fn escape_for_make(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for c in value.chars() {
match c {
'$' => result.push_str("$$"),
'#' => result.push_str("\\#"),
'\\' => result.push_str("\\\\"),
_ => result.push(c),
}
}
result
}
fn escape_for_make_eval(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for c in value.chars() {
match c {
'$' => result.push_str("$$$$"),
'#' => result.push_str("\\#"),
'\\' => result.push_str("\\\\"),
_ => result.push(c),
}
}
result
}