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

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(())
}