From 53a8b23d124fe2fbc43a183e17d48d439f21a639 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Fri, 13 Nov 2026 10:19:50 +0200 Subject: [PATCH] feat: read config from ~/.config/stackdog and add stackdog init Configuration resolution, nearest first: real environment variables, then ./.env in the working directory, then ~/.config/stackdog/.env ($XDG_CONFIG_HOME/stackdog/.env when set). dotenvy never overrides an already-set variable, so the nearest file wins. A missing file is no longer a warning: every setting has a default. stackdog init writes the starter config to the config directory and never overwrites an existing file, since it holds credentials. The template is the repo's .env.sample, embedded in the binary so it works for cargo install and brew, where no sample file sits next to the binary. Verified by hand against a throwaway HOME: init creates the file, a second run reports it exists and leaves it alone. 469+8+108 tests pass, clippy -D warnings and fmt clean. --- .env.sample | 5 +++++ CHANGELOG.md | 9 ++++++++ README.md | 10 ++++++++- src/cli.rs | 3 +++ src/main.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/.env.sample b/.env.sample index 9abb8c2..2c6115c 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,8 @@ +# Stackdog configuration. +# Copy to .env in the working directory, or to ~/.config/stackdog/.env +# (`stackdog init` writes this file there). Real environment variables and +# the nearer file win; everything here is optional and defaults are safe. + #APP_HOST=localhost # in container APP_HOST=0.0.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fef0e5..048fc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Configuration is also read from `~/.config/stackdog/.env` + (`$XDG_CONFIG_HOME/stackdog/.env` when set), behind the working-directory + `.env`: the nearest file wins and real environment variables win over both. + A missing file is no longer a warning — every setting has a default. +- `stackdog init` writes the starter config to that directory and never + overwrites an existing file (it holds credentials). + ## [0.3.0] - 2026-11-09 ### Added diff --git a/README.md b/README.md index 3678b43..193cc96 100644 --- a/README.md +++ b/README.md @@ -860,7 +860,7 @@ stackdog/ # 1. Clone and setup git clone https://github.com/trydirect/stackdog cd stackdog -cp .env.sample .env +cp .env.sample .env # or run `stackdog init` once built # 2. Build cargo build @@ -889,6 +889,14 @@ bpftool version uname -r # Should be 4.19+ ``` +### Where the config lives + +`stackdog` reads settings from three places, nearest first: real environment +variables, then `.env` in the working directory, then `~/.config/stackdog/.env` +(`$XDG_CONFIG_HOME/stackdog/.env` when set). Every setting has a default, so +the app runs with no config at all. `stackdog init` writes a starter file to +the config directory and never overwrites an existing one. + --- ## 🤝 Contributing diff --git a/src/cli.rs b/src/cli.rs index d1a5391..98d7b1e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -37,6 +37,9 @@ pub enum Command { /// Inspect and repair the Stackdog firewall rules Firewall(FirewallCommand), + + /// Write a starter config to ~/.config/stackdog/.env + Init, } #[derive(Args, Debug, Clone)] diff --git a/src/main.rs b/src/main.rs index 00c050f..6be7b40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,13 +32,11 @@ use tracing_subscriber::FmtSubscriber; #[actix_rt::main] async fn main() -> io::Result<()> { - // Load environment - if let Err(err) = dotenvy::dotenv() { - eprintln!( - "Warning: could not load .env file ({}). Continuing with existing environment.", - err - ); - } + // Parse CLI first, then load the environment: real variables always + // win (dotenvy never overrides them), then ./.env for project-local + // setups, then the service-style config in ~/.config/stackdog/. + let cli = Cli::parse(); + load_environment(!matches!(cli.command, Some(Command::Init))); // Parse CLI arguments let cli = Cli::parse(); @@ -103,11 +101,62 @@ async fn main() -> io::Result<()> { cli::FirewallSubcommand::List => run_firewall_list().await, cli::FirewallSubcommand::Reconcile => run_firewall_reconcile().await, }, + Some(Command::Init) => run_init(), // Default: serve (backward compatible) Some(Command::Serve) | None => run_serve().await, } } +/// Where the service-style config lives: `$XDG_CONFIG_HOME/stackdog`, +/// falling back to `~/.config/stackdog`. +fn config_dir() -> Option { + if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return Some(std::path::PathBuf::from(xdg).join("stackdog")); + } + } + env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".config").join("stackdog")) +} + +/// Load `.env` from the working directory and from [`config_dir`]. The +/// nearest file wins: dotenvy never overrides a variable that is already +/// set, and the working directory is loaded first. Real environment +/// variables win over both. With `quiet`, a missing file is silent — used +/// by `stackdog init`, which exists to create one. +fn load_environment(quiet: bool) { + let local = dotenvy::dotenv(); + let home = config_dir().map(|dir| dotenvy::from_path(dir.join(".env"))); + if local.is_err() && home.as_ref().is_none_or(Result::is_err) && !quiet { + eprintln!( + "Note: no .env in the working directory or {:?}. Using the existing environment; every setting has a default. Run `stackdog init` to write a starter config.", + config_dir() + ); + } +} + +/// Write a starter config to [`config_dir`]. An existing file is never +/// overwritten: it holds credentials. +fn run_init() -> io::Result<()> { + let dir = config_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "cannot determine the config directory (no HOME or XDG_CONFIG_HOME)", + ) + })?; + std::fs::create_dir_all(&dir)?; + let path = dir.join(".env"); + if path.exists() { + println!("{} already exists; not overwriting it.", path.display()); + return Ok(()); + } + std::fs::write(&path, include_str!("../.env.sample"))?; + println!("Wrote {}.", path.display()); + println!( + "Edit it to set notification channels and log paths, then start stackdog again." + ); + Ok(()) +} + async fn run_serve() -> io::Result<()> { let app_host = env::var("APP_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let app_port = env::var("APP_PORT").unwrap_or_else(|_| "5000".to_string());