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
This commit is contained in:
voson
2026-03-18 16:19:11 +08:00
parent e6db23bd6d
commit 9620ff1923
6 changed files with 202 additions and 16 deletions

42
Cargo.lock generated
View File

@@ -305,6 +305,27 @@ dependencies = [
"subtle", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -892,6 +913,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -1075,6 +1102,17 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1163,12 +1201,12 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "secrets" name = "secrets"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"clap", "clap",
"dotenvy", "dirs",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",

View File

@@ -1,13 +1,13 @@
[package] [package]
name = "secrets" name = "secrets"
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0.102" anyhow = "1.0.102"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
clap = { version = "4.6.0", features = ["derive", "env"] } clap = { version = "4.6.0", features = ["derive", "env"] }
dotenvy = "0.15.7" dirs = "6.0.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }

54
src/commands/config.rs Normal file
View File

@@ -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 <DATABASE_URL>");
}
}
}
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()
}

View File

@@ -1,4 +1,5 @@
pub mod add; pub mod add;
pub mod config;
pub mod delete; pub mod delete;
pub mod search; pub mod search;
pub mod update; pub mod update;

70
src/config.rs Normal file
View File

@@ -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<String>,
}
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<Config> {
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<String> {
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 <DATABASE_URL>\n")
}

View File

@@ -1,10 +1,10 @@
mod commands; mod commands;
mod config;
mod db; mod db;
mod models; mod models;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use dotenvy::dotenv;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@@ -13,8 +13,8 @@ use dotenvy::dotenv;
about = "Secrets & config manager backed by PostgreSQL" about = "Secrets & config manager backed by PostgreSQL"
)] )]
struct Cli { struct Cli {
/// Database URL (or set DATABASE_URL env var) /// Database URL, overrides saved config (one-time override)
#[arg(long, env = "DATABASE_URL", global = true, default_value = "")] #[arg(long, global = true, default_value = "")]
db_url: String, db_url: String,
#[command(subcommand)] #[command(subcommand)]
@@ -107,22 +107,44 @@ enum Commands {
#[arg(long = "remove-secret")] #[arg(long = "remove-secret")]
remove_secrets: Vec<String>, remove_secrets: Vec<String>,
}, },
/// 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] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
dotenv().ok();
let cli = Cli::parse(); let cli = Cli::parse();
let db_url = if cli.db_url.is_empty() { // config 子命令不需要数据库连接,提前处理
std::env::var("DATABASE_URL").map_err(|_| { if let Commands::Config { action } = &cli.command {
anyhow::anyhow!("DATABASE_URL not set. Use --db-url or set DATABASE_URL env var.") let cmd_action = match action {
})? ConfigAction::SetDb { url } => {
} else { commands::config::ConfigAction::SetDb { url: url.clone() }
cli.db_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?; let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?; db::migrate(&pool).await?;
@@ -188,6 +210,7 @@ async fn main() -> Result<()> {
) )
.await?; .await?;
} }
Commands::Config { .. } => unreachable!(),
} }
Ok(()) Ok(())