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
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:
@@ -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('\'', "'\\''"))
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user