feat: Subcommand to edit the config directly instead of having to find the file

This commit is contained in:
2025-09-15 09:25:09 -06:00
parent 924976ee1b
commit dbb4d265c4
8 changed files with 144 additions and 49 deletions
-33
View File
@@ -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
Generated
+1 -1
View File
@@ -1576,7 +1576,7 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gman"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"argon2",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gman"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "Universal secret management and injection tool"
+30 -5
View File
@@ -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() {
+24
View File
@@ -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;
+20 -1
View File
@@ -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<String>,
#[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 {
+16 -6
View File
@@ -116,8 +116,7 @@ fn resolve_git_username(git: &Path, name: Option<&String>) -> Result<String> {
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<String> {
@@ -126,11 +125,10 @@ fn resolve_git_email(git: &Path, email: Option<&String>) -> Result<String> {
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<PathBuf> {
pub(in crate::providers) fn resolve_git(override_path: Option<&PathBuf>) -> Result<PathBuf> {
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<PathBuf> {
Ok(PathBuf::from("git"))
}
fn ensure_git_available(git: &Path) -> Result<()> {
pub(in crate::providers) fn default_git_username(git: &Path) -> Result<String> {
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<String> {
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())
+52 -2
View File
@@ -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"),
}