feat: Subcommand to edit the config directly instead of having to find the file
This commit is contained in:
@@ -7,36 +7,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [0.0.1] - 2025-09-10
|
## [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
@@ -1576,7 +1576,7 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gman"
|
name = "gman"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gman"
|
name = "gman"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||||
description = "Universal secret management and injection tool"
|
description = "Universal secret management and injection tool"
|
||||||
|
|||||||
+30
-5
@@ -1,19 +1,21 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::Subcommand;
|
||||||
use clap::{
|
use clap::{
|
||||||
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
|
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::execute;
|
||||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
|
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::io::{self, IsTerminal, Read, Write};
|
||||||
use std::panic::PanicHookInfo;
|
use std::panic::PanicHookInfo;
|
||||||
|
|
||||||
use crate::cli::wrap_and_run_command;
|
use crate::cli::wrap_and_run_command;
|
||||||
|
use dialoguer::Editor;
|
||||||
use std::panic;
|
use std::panic;
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
|
use validator::Validate;
|
||||||
|
use crate::utils::persist_config_file;
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod command;
|
mod command;
|
||||||
@@ -103,6 +105,9 @@ enum Commands {
|
|||||||
/// Sync secrets with remote storage (if supported by the provider)
|
/// Sync secrets with remote storage (if supported by the provider)
|
||||||
Sync {},
|
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
|
/// Wrap the provided command and supply it with secrets as environment variables or as
|
||||||
/// configured in a corresponding run profile
|
/// configured in a corresponding run profile
|
||||||
#[command(external_subcommand)]
|
#[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 {} => {
|
Commands::Sync {} => {
|
||||||
secrets_provider.sync().await.map(|_| {
|
secrets_provider.sync().await.map(|_| {
|
||||||
if cli.output.is_none() {
|
if cli.output.is_none() {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use gman::config::{Config, get_config_file_path};
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use log4rs::append::console::ConsoleAppender;
|
use log4rs::append::console::ConsoleAppender;
|
||||||
use log4rs::append::file::FileAppender;
|
use log4rs::append::file::FileAppender;
|
||||||
@@ -60,6 +62,28 @@ pub fn get_log_path() -> PathBuf {
|
|||||||
dir.join("gman.log")
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::utils::get_log_path;
|
use crate::utils::get_log_path;
|
||||||
|
|||||||
+20
-1
@@ -19,6 +19,8 @@
|
|||||||
//! };
|
//! };
|
||||||
//! rc.validate().unwrap();
|
//! rc.validate().unwrap();
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
use collections::HashSet;
|
||||||
use crate::providers::local::LocalProvider;
|
use crate::providers::local::LocalProvider;
|
||||||
use crate::providers::{SecretProvider, SupportedProvider};
|
use crate::providers::{SecretProvider, SupportedProvider};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -28,7 +30,7 @@ use serde_with::serde_as;
|
|||||||
use serde_with::skip_serializing_none;
|
use serde_with::skip_serializing_none;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::{env, fs};
|
use std::{collections, env, fs};
|
||||||
use validator::{Validate, ValidationError};
|
use validator::{Validate, ValidationError};
|
||||||
|
|
||||||
#[skip_serializing_none]
|
#[skip_serializing_none]
|
||||||
@@ -182,6 +184,7 @@ impl ProviderConfig {
|
|||||||
/// ```
|
/// ```
|
||||||
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[validate(schema(function = "default_provider_exists"))]
|
#[validate(schema(function = "default_provider_exists"))]
|
||||||
|
#[validate(schema(function = "providers_names_are_unique"))]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub default_provider: Option<String>,
|
pub default_provider: Option<String>,
|
||||||
#[validate(length(min = 1))]
|
#[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 {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -116,8 +116,7 @@ fn resolve_git_username(git: &Path, name: Option<&String>) -> Result<String> {
|
|||||||
return Ok(name.to_string());
|
return Ok(name.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
run_git_config_capture(git, &["config", "user.name"])
|
default_git_username(git)
|
||||||
.with_context(|| "unable to determine git username")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_git_email(git: &Path, email: Option<&String>) -> Result<String> {
|
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());
|
return Ok(email.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
run_git_config_capture(git, &["config", "user.email"])
|
default_git_email(git)
|
||||||
.with_context(|| "unable to determine git user email")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
debug!("Resolving git executable");
|
||||||
if let Some(p) = override_path {
|
if let Some(p) = override_path {
|
||||||
return Ok(p.to_path_buf());
|
return Ok(p.to_path_buf());
|
||||||
@@ -141,7 +139,19 @@ fn resolve_git(override_path: Option<&PathBuf>) -> Result<PathBuf> {
|
|||||||
Ok(PathBuf::from("git"))
|
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)
|
let ok = Command::new(git)
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
|
|||||||
+52
-2
@@ -6,7 +6,10 @@ use std::{env, fs};
|
|||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
use crate::config::{Config, get_config_file_path, load_config};
|
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::providers::{SecretProvider, SupportedProvider};
|
||||||
use crate::{
|
use crate::{
|
||||||
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
|
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<()> {
|
async fn sync(&mut self) -> Result<()> {
|
||||||
let mut config_changed = false;
|
let mut config_changed = false;
|
||||||
|
let git = resolve_git(self.git_executable.as_ref())?;
|
||||||
|
ensure_git_available(&git)?;
|
||||||
|
|
||||||
if self.git_branch.is_none() {
|
if self.git_branch.is_none() {
|
||||||
config_changed = true;
|
config_changed = true;
|
||||||
@@ -189,6 +194,39 @@ impl SecretProvider for LocalProvider {
|
|||||||
self.git_remote_url = Some(remote);
|
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 {
|
if config_changed {
|
||||||
self.persist_git_settings_to_config()?;
|
self.persist_git_settings_to_config()?;
|
||||||
}
|
}
|
||||||
@@ -223,6 +261,9 @@ impl LocalProvider {
|
|||||||
if matches_name || target_name.is_none() {
|
if matches_name || target_name.is_none() {
|
||||||
provider_def.git_branch = self.git_branch.clone();
|
provider_def.git_branch = self.git_branch.clone();
|
||||||
provider_def.git_remote_url = self.git_remote_url.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;
|
updated = true;
|
||||||
if matches_name {
|
if matches_name {
|
||||||
@@ -564,7 +605,7 @@ mod tests {
|
|||||||
.expect("persist ok");
|
.expect("persist ok");
|
||||||
|
|
||||||
let content = fs::read_to_string(&cfg_path).unwrap();
|
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_eq!(cfg.default_provider.as_deref(), Some("local"));
|
||||||
assert!(cfg.run_configs.is_some());
|
assert!(cfg.run_configs.is_some());
|
||||||
@@ -579,6 +620,15 @@ mod tests {
|
|||||||
provider_def.git_remote_url.as_deref(),
|
provider_def.git_remote_url.as_deref(),
|
||||||
Some("git@github.com:user/repo.git")
|
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"),
|
_ => panic!("expected local provider"),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user