From dbb4d265c45c6430606a2256ff2abe3455fbc29e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 15 Sep 2025 09:25:09 -0600 Subject: [PATCH] feat: Subcommand to edit the config directly instead of having to find the file --- CHANGELOG.md | 33 ------------------------ Cargo.lock | 2 +- Cargo.toml | 2 +- src/bin/gman/main.rs | 35 +++++++++++++++++++++---- src/bin/gman/utils.rs | 24 +++++++++++++++++ src/config.rs | 21 ++++++++++++++- src/providers/git_sync.rs | 22 +++++++++++----- src/providers/local.rs | 54 +++++++++++++++++++++++++++++++++++++-- 8 files changed, 144 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a509c..c28845a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,36 +7,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.0.1] - 2025-09-10 -## v0.1.0 (2025-09-15) - -### Fix - -- Pass the changelog to the GHA properly using a file -- Potential bug in changelog variable generation -- Revert back hacky stuff so I can test with act now -- Attempting to use pre-generated bindgens for the aws-lc-sys library -- Install openSSL differently to make this work -- Address edge case for unknown_musl targets -- Install LLVM prereqs for release flow -- Updated the release flow to install the external bindgen-cli - -## v0.0.1 (2025-09-12) - -### Feat - -- Azure Key Vault support -- GCP Secret Manager support -- Full AWS SecretsManager support -- AWS Secrets Manager support -- Added two new flags to output where gman writes logs to and where it expects the config file to live - -### Fix - -- Made the vault file location more fault tolerant -- Attempting to maybe be a bit more explicit about config file handling to fix MacOS tests - -### Refactor - -- Refactor configuration structs directly into the provider definition to simplify validation, structs, and future extensions -- Made the creation of the log directories a bit more fault tolerant -- Renamed the provider field in a config file to type to make things a little easier to understand; also removed husky diff --git a/Cargo.lock b/Cargo.lock index 544da2b..25969e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1576,7 +1576,7 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gman" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 61a597d..fc29995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gman" -version = "0.1.0" +version = "0.1.1" edition = "2024" authors = ["Alex Clarke "] description = "Universal secret management and injection tool" diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index 6dc9120..97c4552 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -1,19 +1,21 @@ +use anyhow::{Context, Result}; +use clap::Subcommand; use clap::{ CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version, }; -use std::ffi::OsString; - -use anyhow::{Context, Result}; -use clap::Subcommand; use crossterm::execute; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; -use gman::config::{get_config_file_path, load_config}; +use gman::config::{Config, get_config_file_path, load_config}; +use std::ffi::OsString; use std::io::{self, IsTerminal, Read, Write}; use std::panic::PanicHookInfo; use crate::cli::wrap_and_run_command; +use dialoguer::Editor; use std::panic; use std::process::exit; +use validator::Validate; +use crate::utils::persist_config_file; mod cli; mod command; @@ -103,6 +105,9 @@ enum Commands { /// Sync secrets with remote storage (if supported by the provider) Sync {}, + /// Open and edit the config file in the default text editor + Config {}, + /// Wrap the provided command and supply it with secrets as environment variables or as /// configured in a corresponding run profile #[command(external_subcommand)] @@ -220,6 +225,26 @@ async fn main() -> Result<()> { } } } + Commands::Config {} => { + let config_yaml = serde_yaml::to_string(&config) + .with_context(|| "failed to serialize existing configuration")?; + let new_config = Editor::new() + .edit(&config_yaml) + .with_context(|| "unable to process user changes")?; + if new_config.is_none() { + println!("✗ No changes made to configuration"); + return Ok(()); + } + + let new_config = new_config.unwrap(); + let new_config: Config = serde_yaml::from_str(&new_config) + .with_context(|| "failed to parse updated configuration")?; + new_config + .validate() + .with_context(|| "updated configuration is invalid")?; + persist_config_file(&new_config)?; + println!("✓ Configuration updated successfully"); + } Commands::Sync {} => { secrets_provider.sync().await.map(|_| { if cli.output.is_none() { diff --git a/src/bin/gman/utils.rs b/src/bin/gman/utils.rs index 4c14136..5fc31c1 100644 --- a/src/bin/gman/utils.rs +++ b/src/bin/gman/utils.rs @@ -1,3 +1,5 @@ +use anyhow::{Context, Result}; +use gman::config::{Config, get_config_file_path}; use log::LevelFilter; use log4rs::append::console::ConsoleAppender; use log4rs::append::file::FileAppender; @@ -60,6 +62,28 @@ pub fn get_log_path() -> PathBuf { dir.join("gman.log") } +pub fn persist_config_file(config: &Config) -> Result<()> { + let config_path = + get_config_file_path().with_context(|| "unable to determine config file path")?; + let ext = config_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml") { + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + let s = serde_yaml::to_string(config)?; + fs::write(&config_path, s) + .with_context(|| format!("failed to write {}", config_path.display()))?; + } else { + confy::store("gman", "config", config) + .with_context(|| "failed to save updated config via confy")?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use crate::utils::get_log_path; diff --git a/src/config.rs b/src/config.rs index 2b086ea..81a7201 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,8 @@ //! }; //! rc.validate().unwrap(); //! ``` + +use collections::HashSet; use crate::providers::local::LocalProvider; use crate::providers::{SecretProvider, SupportedProvider}; use anyhow::{Context, Result}; @@ -28,7 +30,7 @@ use serde_with::serde_as; use serde_with::skip_serializing_none; use std::borrow::Cow; use std::path::PathBuf; -use std::{env, fs}; +use std::{collections, env, fs}; use validator::{Validate, ValidationError}; #[skip_serializing_none] @@ -182,6 +184,7 @@ impl ProviderConfig { /// ``` #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[validate(schema(function = "default_provider_exists"))] +#[validate(schema(function = "providers_names_are_unique"))] pub struct Config { pub default_provider: Option, #[validate(length(min = 1))] @@ -211,6 +214,22 @@ fn default_provider_exists(config: &Config) -> Result<(), ValidationError> { } } +fn providers_names_are_unique(config: &Config) -> Result<(), ValidationError> { + let mut names = HashSet::new(); + for provider in &config.providers { + if let Some(name) = &provider.name { + if !names.insert(name) { + let mut err = ValidationError::new("duplicate_provider_name"); + err.message = Some(Cow::Borrowed( + "Provider names must be unique; duplicate found", + )); + return Err(err); + } + } + } + Ok(()) +} + impl Default for Config { fn default() -> Self { Self { diff --git a/src/providers/git_sync.rs b/src/providers/git_sync.rs index 8cba7e5..441ae4d 100644 --- a/src/providers/git_sync.rs +++ b/src/providers/git_sync.rs @@ -116,8 +116,7 @@ fn resolve_git_username(git: &Path, name: Option<&String>) -> Result { return Ok(name.to_string()); } - run_git_config_capture(git, &["config", "user.name"]) - .with_context(|| "unable to determine git username") + default_git_username(git) } fn resolve_git_email(git: &Path, email: Option<&String>) -> Result { @@ -126,11 +125,10 @@ fn resolve_git_email(git: &Path, email: Option<&String>) -> Result { return Ok(email.to_string()); } - run_git_config_capture(git, &["config", "user.email"]) - .with_context(|| "unable to determine git user email") + default_git_email(git) } -fn resolve_git(override_path: Option<&PathBuf>) -> Result { +pub(in crate::providers) fn resolve_git(override_path: Option<&PathBuf>) -> Result { debug!("Resolving git executable"); if let Some(p) = override_path { return Ok(p.to_path_buf()); @@ -141,7 +139,19 @@ fn resolve_git(override_path: Option<&PathBuf>) -> Result { Ok(PathBuf::from("git")) } -fn ensure_git_available(git: &Path) -> Result<()> { +pub(in crate::providers) fn default_git_username(git: &Path) -> Result { + debug!("Checking for default git username"); + run_git_config_capture(git, &["config", "user.name"]) + .with_context(|| "unable to determine git user name") +} + +pub(in crate::providers) fn default_git_email(git: &Path) -> Result { + debug!("Checking for default git username"); + run_git_config_capture(git, &["config", "user.email"]) + .with_context(|| "unable to determine git user email") +} + +pub(in crate::providers) fn ensure_git_available(git: &Path) -> Result<()> { let ok = Command::new(git) .arg("--version") .stdout(Stdio::null()) diff --git a/src/providers/local.rs b/src/providers/local.rs index eea7c38..8e53e6d 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -6,7 +6,10 @@ use std::{env, fs}; use zeroize::Zeroize; use crate::config::{Config, get_config_file_path, load_config}; -use crate::providers::git_sync::{SyncOpts, repo_name_from_url, sync_and_push}; +use crate::providers::git_sync::{ + SyncOpts, default_git_email, default_git_username, ensure_git_available, repo_name_from_url, + resolve_git, sync_and_push, +}; use crate::providers::{SecretProvider, SupportedProvider}; use crate::{ ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION, @@ -156,6 +159,8 @@ impl SecretProvider for LocalProvider { async fn sync(&mut self) -> Result<()> { let mut config_changed = false; + let git = resolve_git(self.git_executable.as_ref())?; + ensure_git_available(&git)?; if self.git_branch.is_none() { config_changed = true; @@ -189,6 +194,39 @@ impl SecretProvider for LocalProvider { self.git_remote_url = Some(remote); } + if self.git_user_name.is_none() { + config_changed = true; + debug!("Prompting user git user name"); + let default_user_name = default_git_username(&git)?.trim().to_string(); + let branch: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter git user name") + .default(default_user_name) + .interact_text()?; + + self.git_user_name = Some(branch); + } + + if self.git_user_email.is_none() { + config_changed = true; + debug!("Prompting user git email"); + let default_user_name = default_git_email(&git)?.trim().to_string(); + let branch: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter git user email") + .validate_with({ + |s: &String| { + if s.contains('@') { + Ok(()) + } else { + Err("not a valid email address".to_string()) + } + } + }) + .default(default_user_name) + .interact_text()?; + + self.git_user_email = Some(branch); + } + if config_changed { self.persist_git_settings_to_config()?; } @@ -223,6 +261,9 @@ impl LocalProvider { if matches_name || target_name.is_none() { provider_def.git_branch = self.git_branch.clone(); provider_def.git_remote_url = self.git_remote_url.clone(); + provider_def.git_user_name = self.git_user_name.clone(); + provider_def.git_user_email = self.git_user_email.clone(); + provider_def.git_executable = self.git_executable.clone(); updated = true; if matches_name { @@ -564,7 +605,7 @@ mod tests { .expect("persist ok"); let content = fs::read_to_string(&cfg_path).unwrap(); - let cfg: crate::config::Config = serde_yaml::from_str(&content).unwrap(); + let cfg: Config = serde_yaml::from_str(&content).unwrap(); assert_eq!(cfg.default_provider.as_deref(), Some("local")); assert!(cfg.run_configs.is_some()); @@ -579,6 +620,15 @@ mod tests { provider_def.git_remote_url.as_deref(), Some("git@github.com:user/repo.git") ); + assert_eq!(provider_def.git_user_name.as_deref(), Some("Test User")); + assert_eq!( + provider_def.git_user_email.as_deref(), + Some("test@example.com") + ); + assert_eq!( + provider_def.git_executable.as_ref(), + Some(&PathBuf::from("/usr/bin/git")) + ); } _ => panic!("expected local provider"), }