feat: secrets CLI MVP — add/search/delete with PostgreSQL JSONB
Some checks failed
Secrets CLI - Build & Release / 检查版本 (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Failing after 41s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 55s
Secrets CLI - Build & Release / 发送通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- Single `secrets` table with namespace/kind/name/tags/metadata/encrypted
- Auto-migrate on startup using uuidv7() primary keys and GIN indexes
- CLI commands: add (upsert, @file support), search (full-text + tags), delete
- Multi-platform Gitea Actions: debian (x86_64-musl), darwin-arm64, windows
  - continue-on-error + timeout-minutes=30 for offline runner tolerance
- VS Code tasks.json for local build/test/seed
- AGENTS.md for AI context

Made-with: Cursor
This commit is contained in:
voson
2026-03-18 14:10:45 +08:00
parent 3b5e26213c
commit 102e394914
16 changed files with 3656 additions and 544 deletions

87
src/commands/add.rs Normal file
View File

@@ -0,0 +1,87 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::fs;
/// Parse "key=value" entries. Value starting with '@' reads from file.
fn parse_kv(entry: &str) -> Result<(String, String)> {
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",
entry
)
})?;
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
Ok((key.to_string(), value))
}
fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (key, value) = parse_kv(entry)?;
map.insert(key, Value::String(value));
}
Ok(Value::Object(map))
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
tags: &[String],
meta_entries: &[String],
secret_entries: &[String],
) -> Result<()> {
let metadata = build_json(meta_entries)?;
let encrypted = build_json(secret_entries)?;
sqlx::query(
r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (namespace, kind, name)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
updated_at = NOW()
"#,
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(tags)
.bind(&metadata)
.bind(&encrypted)
.execute(pool)
.await?;
println!("Added: [{}/{}] {}", namespace, kind, name);
if !tags.is_empty() {
println!(" tags: {}", tags.join(", "));
}
if !meta_entries.is_empty() {
let keys: Vec<&str> = meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" metadata: {}", keys.join(", "));
}
if !secret_entries.is_empty() {
let keys: Vec<&str> = secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" secrets: {}", keys.join(", "));
}
Ok(())
}

20
src/commands/delete.rs Normal file
View File

@@ -0,0 +1,20 @@
use anyhow::Result;
use sqlx::PgPool;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
let result = sqlx::query(
"DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(namespace)
.bind(kind)
.bind(name)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
println!("Not found: [{}/{}] {}", namespace, kind, name);
} else {
println!("Deleted: [{}/{}] {}", namespace, kind, name);
}
Ok(())
}

3
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod add;
pub mod delete;
pub mod search;

104
src/commands/search.rs Normal file
View File

@@ -0,0 +1,104 @@
use anyhow::Result;
use sqlx::PgPool;
use crate::models::Secret;
pub async fn run(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
tag: Option<&str>,
query: Option<&str>,
show_secrets: bool,
) -> Result<()> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if tag.is_some() {
conditions.push(format!("tags @> ARRAY[${}]", idx));
idx += 1;
}
if query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} OR namespace ILIKE ${i} OR kind ILIKE ${i} OR metadata::text ILIKE ${i} OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i}))",
i = idx
));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY namespace, kind, name",
where_clause
);
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = namespace {
q = q.bind(v);
}
if let Some(v) = kind {
q = q.bind(v);
}
if let Some(v) = tag {
q = q.bind(v);
}
if let Some(v) = query {
q = q.bind(format!("%{}%", v));
}
let rows = q.fetch_all(pool).await?;
if rows.is_empty() {
println!("No records found.");
return Ok(());
}
for row in &rows {
println!(
"[{}/{}] {}",
row.namespace, row.kind, row.name,
);
println!(" id: {}", row.id);
if !row.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", "));
}
let meta_obj = row.metadata.as_object();
if let Some(m) = meta_obj {
if !m.is_empty() {
println!(" metadata: {}", serde_json::to_string_pretty(&row.metadata)?);
}
}
if show_secrets {
println!(" secrets: {}", serde_json::to_string_pretty(&row.encrypted)?);
} else {
let keys: Vec<String> = row
.encrypted
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
if !keys.is_empty() {
println!(" secrets: [{}] (--show-secrets to reveal)", keys.join(", "));
}
}
println!(" created: {}", row.created_at.format("%Y-%m-%d %H:%M:%S UTC"));
println!();
}
println!("{} record(s) found.", rows.len());
Ok(())
}