From 9620ff1923fa6be82c67b68f9b9955c105869ffc Mon Sep 17 00:00:00 2001 From: voson Date: Wed, 18 Mar 2026 16:19:11 +0800 Subject: [PATCH] feat(config): persist database URL to ~/.config/secrets/config.toml - Add 'secrets config set-db/show/path' subcommands - Remove dotenvy and DATABASE_URL env var support - Config file created with 0600 permission - Bump version to 0.3.0 Made-with: Cursor --- Cargo.lock | 42 +++++++++++++++++++++++-- Cargo.toml | 4 +-- src/commands/config.rs | 54 ++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/config.rs | 70 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 47 ++++++++++++++++++++-------- 6 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 src/commands/config.rs create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index b116ba8..45964cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,6 +305,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -892,6 +913,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking" version = "2.2.1" @@ -1075,6 +1102,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "ring" version = "0.17.14" @@ -1163,12 +1201,12 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "chrono", "clap", - "dotenvy", + "dirs", "serde", "serde_json", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index ce80f52..c8863eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "secrets" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] anyhow = "1.0.102" chrono = { version = "0.4.44", features = ["serde"] } clap = { version = "4.6.0", features = ["derive", "env"] } -dotenvy = "0.15.7" +dirs = "6.0.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 0000000..44d632f --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,54 @@ +use crate::config::{self, Config, config_path}; +use anyhow::Result; + +pub enum ConfigAction { + SetDb { url: String }, + Show, + Path, +} + +pub async fn run(action: ConfigAction) -> Result<()> { + match action { + ConfigAction::SetDb { url } => { + let cfg = Config { + database_url: Some(url.clone()), + }; + config::save_config(&cfg)?; + println!("✓ 数据库连接串已保存到: {}", config_path().display()); + println!(" {}", mask_password(&url)); + } + ConfigAction::Show => { + let cfg = config::load_config()?; + match cfg.database_url { + Some(url) => { + println!("database_url = {}", mask_password(&url)); + println!("配置文件: {}", config_path().display()); + } + None => { + println!("未配置数据库连接串。"); + println!("请运行:secrets config set-db "); + } + } + } + ConfigAction::Path => { + println!("{}", config_path().display()); + } + } + Ok(()) +} + +/// 将 postgres://user:password@host/db 中的密码替换为 *** +fn mask_password(url: &str) -> String { + if let Some(at_pos) = url.rfind('@') + && let Some(scheme_end) = url.find("://") + { + let prefix = &url[..scheme_end + 3]; + let credentials = &url[scheme_end + 3..at_pos]; + let rest = &url[at_pos..]; + if let Some(colon_pos) = credentials.find(':') { + let user = &credentials[..colon_pos]; + return format!("{}{}:***{}", prefix, user, rest); + } + } + url.to_string() +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 60032db..9f6f0e8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod config; pub mod delete; pub mod search; pub mod update; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9517129 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,70 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Config { + pub database_url: Option, +} + +pub fn config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("~/.config")) + .join("secrets") +} + +pub fn config_path() -> PathBuf { + config_dir().join("config.toml") +} + +pub fn load_config() -> Result { + let path = config_path(); + if !path.exists() { + return Ok(Config::default()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("读取配置文件失败: {}", path.display()))?; + let config: Config = toml::from_str(&content) + .with_context(|| format!("解析配置文件失败: {}", path.display()))?; + Ok(config) +} + +pub fn save_config(config: &Config) -> Result<()> { + let dir = config_dir(); + fs::create_dir_all(&dir).with_context(|| format!("创建配置目录失败: {}", dir.display()))?; + + let path = config_path(); + let content = toml::to_string_pretty(config).context("序列化配置失败")?; + fs::write(&path, &content).with_context(|| format!("写入配置文件失败: {}", path.display()))?; + + // 设置文件权限为 0600(仅所有者读写) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(&path, perms) + .with_context(|| format!("设置文件权限失败: {}", path.display()))?; + } + + Ok(()) +} + +/// 按优先级解析数据库连接串: +/// 1. --db-url CLI 参数(非空时使用) +/// 2. ~/.config/secrets/config.toml 中的 database_url +/// 3. 报错并提示用户配置 +pub fn resolve_db_url(cli_db_url: &str) -> Result { + if !cli_db_url.is_empty() { + return Ok(cli_db_url.to_string()); + } + + let config = load_config()?; + if let Some(url) = config.database_url + && !url.is_empty() + { + return Ok(url); + } + + anyhow::bail!("数据库未配置。请先运行:\n\n secrets config set-db \n") +} diff --git a/src/main.rs b/src/main.rs index a41bb6d..b0688a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ mod commands; +mod config; mod db; mod models; use anyhow::Result; use clap::{Parser, Subcommand}; -use dotenvy::dotenv; #[derive(Parser)] #[command( @@ -13,8 +13,8 @@ use dotenvy::dotenv; about = "Secrets & config manager backed by PostgreSQL" )] struct Cli { - /// Database URL (or set DATABASE_URL env var) - #[arg(long, env = "DATABASE_URL", global = true, default_value = "")] + /// Database URL, overrides saved config (one-time override) + #[arg(long, global = true, default_value = "")] db_url: String, #[command(subcommand)] @@ -107,22 +107,44 @@ enum Commands { #[arg(long = "remove-secret")] remove_secrets: Vec, }, + + /// Manage CLI configuration (database connection, etc.) + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Save database URL to config file (~/.config/secrets/config.toml) + SetDb { + /// PostgreSQL connection string + url: String, + }, + /// Show current configuration + Show, + /// Print path to config file + Path, } #[tokio::main] async fn main() -> Result<()> { - dotenv().ok(); - let cli = Cli::parse(); - let db_url = if cli.db_url.is_empty() { - std::env::var("DATABASE_URL").map_err(|_| { - anyhow::anyhow!("DATABASE_URL not set. Use --db-url or set DATABASE_URL env var.") - })? - } else { - cli.db_url.clone() - }; + // config 子命令不需要数据库连接,提前处理 + if let Commands::Config { action } = &cli.command { + let cmd_action = match action { + ConfigAction::SetDb { url } => { + commands::config::ConfigAction::SetDb { url: url.clone() } + } + ConfigAction::Show => commands::config::ConfigAction::Show, + ConfigAction::Path => commands::config::ConfigAction::Path, + }; + return commands::config::run(cmd_action).await; + } + let db_url = config::resolve_db_url(&cli.db_url)?; let pool = db::create_pool(&db_url).await?; db::migrate(&pool).await?; @@ -188,6 +210,7 @@ async fn main() -> Result<()> { ) .await?; } + Commands::Config { .. } => unreachable!(), } Ok(())