feat(run): 选择性字段注入、dry-run 预览、默认 JSON 输出
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m20s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m4s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m13s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled

- run 新增 -s/--secret 字段过滤,只注入指定字段到子进程(最小权限)
- run 新增 --dry-run 模式,输出变量名与来源映射,不执行命令、不暴露值
- run 新增 -o 参数,dry-run 默认 JSON 输出
- 默认输出格式改为始终 json,移除 TTY 自动切换逻辑,-o text 供人类使用
- build_injected_env_map 签名从 &[SecretField] 改为 &[&SecretField]
- 更新 AGENTS.md、README.md、.vscode/tasks.json
- version: 0.9.5 → 0.9.6

Made-with: Cursor
This commit is contained in:
voson
2026-03-19 17:39:09 +08:00
parent 3a5ec92bf0
commit 955acfe9ec
9 changed files with 286 additions and 212 deletions

View File

@@ -1,45 +1,57 @@
use anyhow::Result;
use serde_json::Value;
use serde_json::json;
use sqlx::PgPool;
use std::collections::HashMap;
use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries};
use crate::output::OutputMode;
pub struct InjectArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub prefix: &'a str,
pub output: OutputMode,
}
pub struct RunArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub secret_fields: &'a [String],
pub prefix: &'a str,
pub dry_run: bool,
pub output: OutputMode,
pub command: &'a [String],
}
/// A single environment variable with its origin for dry-run display.
pub struct EnvMapping {
pub var_name: String,
pub source: String,
pub field: String,
}
struct CollectArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
secret_fields: &'a [String],
prefix: &'a str,
}
/// Fetch entries matching the filter and build a flat env map (decrypted secrets only, no metadata).
pub async fn collect_env_map(
/// If `secret_fields` is non-empty, only those fields are decrypted and included.
async fn collect_env_map(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
prefix: &str,
args: &CollectArgs<'_>,
master_key: &[u8; 32],
) -> Result<HashMap<String, String>> {
if namespace.is_none() && kind.is_none() && name.is_none() && tags.is_empty() {
if args.namespace.is_none()
&& args.kind.is_none()
&& args.name.is_none()
&& args.tags.is_empty()
{
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for inject/run"
"At least one filter (--namespace, --kind, --name, or --tag) is required for run"
);
}
let entries = fetch_entries(pool, namespace, kind, name, tags, None).await?;
let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
@@ -50,8 +62,17 @@ pub async fn collect_env_map(
let mut map = HashMap::new();
for entry in &entries {
let empty = vec![];
let fields = fields_map.get(&entry.id).unwrap_or(&empty);
let row_map = build_injected_env_map(pool, entry, prefix, master_key, fields).await?;
let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
for (k, v) in row_map {
map.insert(k, v);
}
@@ -59,64 +80,152 @@ pub async fn collect_env_map(
Ok(map)
}
/// `inject` command: print env vars to stdout.
pub async fn run_inject(pool: &PgPool, args: InjectArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
match args.output {
OutputMode::Json => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string_pretty(&Value::Object(obj))?);
}
OutputMode::JsonCompact => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string(&Value::Object(obj))?);
}
_ => {
let mut pairs: Vec<(String, String)> = env_map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
println!("{}={}", k, shell_quote(&v));
}
}
/// Like `collect_env_map` but also returns per-variable source info for dry-run display.
async fn collect_env_map_with_source(
pool: &PgPool,
args: &CollectArgs<'_>,
master_key: &[u8; 32],
) -> Result<(HashMap<String, String>, Vec<EnvMapping>)> {
if args.namespace.is_none()
&& args.kind.is_none()
&& args.name.is_none()
&& args.tags.is_empty()
{
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for run"
);
}
let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
Ok(())
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let fields_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let mut map = HashMap::new();
let mut mappings: Vec<EnvMapping> = Vec::new();
for entry in &entries {
let empty = vec![];
let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
let source = format!("{}/{}/{}", entry.namespace, entry.kind, entry.name);
for field in &filtered_fields {
let var_name = format!(
"{}_{}",
env_prefix_name(&entry.name, args.prefix),
field.field_name.to_uppercase().replace(['-', '.'], "_")
);
if row_map.contains_key(&var_name) {
mappings.push(EnvMapping {
var_name: var_name.clone(),
source: source.clone(),
field: field.field_name.clone(),
});
}
}
for (k, v) in row_map {
map.insert(k, v);
}
}
Ok((map, mappings))
}
fn env_prefix_name(entry_name: &str, prefix: &str) -> String {
let name_part = entry_name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
)
}
}
/// `run` command: inject secrets into a child process environment and execute.
/// With `--dry-run`, prints the variable mapping (names and sources only) without executing.
pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
if args.command.is_empty() {
if !args.dry_run && args.command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
let collect = CollectArgs {
namespace: args.namespace,
kind: args.kind,
name: args.name,
tags: args.tags,
secret_fields: args.secret_fields,
prefix: args.prefix,
};
if args.dry_run {
let (env_map, mappings) = collect_env_map_with_source(pool, &collect, master_key).await?;
let total_vars = env_map.len();
let total_records = {
let mut seen = std::collections::HashSet::new();
for m in &mappings {
seen.insert(&m.source);
}
seen.len()
};
match args.output {
OutputMode::Text => {
for m in &mappings {
println!("{:<40} <- {} :: {}", m.var_name, m.source, m.field);
}
println!("---");
println!(
"{} variable(s) from {} record(s).",
total_vars, total_records
);
}
OutputMode::Json | OutputMode::JsonCompact => {
let vars: Vec<_> = mappings
.iter()
.map(|m| {
json!({
"name": m.var_name,
"source": m.source,
"field": m.field,
})
})
.collect();
let out = json!({
"variables": vars,
"total_vars": total_vars,
"total_records": total_records,
});
if args.output == OutputMode::Json {
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("{}", serde_json::to_string(&out)?);
}
}
}
return Ok(());
}
let env_map = collect_env_map(pool, &collect, master_key).await?;
tracing::debug!(
vars = env_map.len(),
@@ -137,7 +246,3 @@ pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -
Ok(())
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

View File

@@ -94,7 +94,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
fn validate_safe_search_args(fields: &[String]) -> Result<()> {
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
anyhow::bail!(
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets inject` or `secrets run` for secrets.",
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets run` for secrets.",
field
);
}
@@ -121,11 +121,11 @@ struct PagedFetchArgs<'a> {
offset: u32,
}
/// A very large limit used when callers need all matching records (export, inject, run).
/// A very large limit used when callers need all matching records (export, run).
/// Postgres will stop scanning when this many rows are found; adjust if needed.
pub const FETCH_ALL_LIMIT: u32 = 100_000;
/// Fetch entries matching the given filters (used by search, inject, run).
/// Fetch entries matching the given filters (used by search, run).
/// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records.
pub async fn fetch_entries(
pool: &PgPool,
@@ -319,7 +319,7 @@ pub async fn build_injected_env_map(
entry: &Entry,
prefix: &str,
master_key: &[u8; 32],
fields: &[SecretField],
fields: &[&SecretField],
) -> Result<HashMap<String, String>> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
@@ -456,7 +456,7 @@ fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> R
Some(fields) if !fields.is_empty() => {
let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect();
println!(" secrets: {}", schema_str.join(", "));
println!(" (use `secrets inject` or `secrets run` to get values)");
println!(" (use `secrets run` to get values)");
}
_ => {}
}