Remediate the CLI/DX findings from the 2026-07-11 audit. B4 — `pic plan` now previews the apply-time desired-state warnings (disabled binding, unreachable endpoint, dangling `[suppress]`), so plan and apply agree for CI review. Wire fields are `#[serde(default)]` (older-server tolerant). B5 — `pic apply` no longer mutates on a first run (no recorded plan) without a preview + confirm, and a large blast radius now triggers an extra confirmation. Both gates check `is_terminal()` before any read — a non-TTY never hangs on stdin and fails closed without `--yes`; `--yes`/`--force` bypass headlessly. `--force` help now notes it also skips these prompts. C3 — `pic files ls`/`get` gain `--group`, mirroring `pic kv --group`, so the shipped group shared-files admin surface is reachable from the CLI. C4 — a shared `require_one_owner(app, group)` helper (cmds/mod.rs) gives one canonical message for the `--app`/`--group` XOR, wired into every such site (kv, files, vars, secrets, suppress, extension-points, triggers ls, scripts deploy). routes ls (positional script_id) and scripts ls (lists all) keep their own shapes deliberately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101 lines
3.4 KiB
Rust
101 lines
3.4 KiB
Rust
//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets.
|
|
//!
|
|
//! Exactly one of `--group` / `--app` selects the owner (mirroring
|
|
//! `pic vars`). Set reads the secret value from stdin (the only safe
|
|
//! channel — inline values would leak into shell history). The value is
|
|
//! sent as a JSON string; pass `--json` to interpret stdin as raw JSON
|
|
//! (numbers, maps, …) instead. `--env` is only meaningful for group
|
|
//! owners (app secrets are env-agnostic).
|
|
|
|
use std::io::Read;
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
|
|
use crate::client::{Client, VarOwnerArg};
|
|
use crate::config;
|
|
use crate::output::{OutputMode, Table};
|
|
|
|
/// Resolve the `--group`/`--app` pair into exactly one owner (shared XOR
|
|
/// message via [`crate::cmds::require_one_owner`]).
|
|
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
|
Ok(match crate::cmds::require_one_owner(app, group)? {
|
|
crate::cmds::OwnerRef::App(a) => VarOwnerArg::App(a),
|
|
crate::cmds::OwnerRef::Group(g) => VarOwnerArg::Group(g),
|
|
})
|
|
}
|
|
|
|
pub async fn ls(
|
|
group: Option<&str>,
|
|
app: Option<&str>,
|
|
env: Option<&str>,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let owner = owner(group, app)?;
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let resp = client.secrets_list(owner, env).await?;
|
|
let mut table = Table::new(["name", "env", "updated_at"]);
|
|
for s in resp.secrets {
|
|
table.row([s.name, s.env, s.updated_at.to_rfc3339()]);
|
|
}
|
|
table.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set(
|
|
group: Option<&str>,
|
|
app: Option<&str>,
|
|
name: &str,
|
|
env: Option<&str>,
|
|
as_json: bool,
|
|
) -> Result<()> {
|
|
let owner = owner(group, app)?;
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let mut buf = String::new();
|
|
std::io::stdin()
|
|
.read_to_string(&mut buf)
|
|
.context("read secret value from stdin")?;
|
|
let trimmed = buf.trim_end_matches(['\n', '\r']);
|
|
if trimmed.is_empty() {
|
|
return Err(anyhow!("empty stdin — secret value must not be empty"));
|
|
}
|
|
let value = if as_json {
|
|
serde_json::from_str(trimmed).map_err(|e| anyhow!("parse stdin as JSON: {e}"))?
|
|
} else {
|
|
serde_json::Value::String(trimmed.to_string())
|
|
};
|
|
client.secrets_set(owner, name, value, env).await?;
|
|
println!("Set secret {name}");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn rm(
|
|
group: Option<&str>,
|
|
app: Option<&str>,
|
|
name: &str,
|
|
env: Option<&str>,
|
|
) -> Result<()> {
|
|
let owner = owner(group, app)?;
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
client.secrets_delete(owner, name, env).await?;
|
|
println!("Deleted secret {name}");
|
|
Ok(())
|
|
}
|
|
|
|
/// `pic secrets read --group <slug> <name>` — fetch and print a secret's
|
|
/// PLAINTEXT value. This is the ONLY command that reveals a secret value,
|
|
/// and it is gated server-side at the owning group (there is no app-secret
|
|
/// equivalent). String values print raw; JSON values print pretty.
|
|
pub async fn read(group: &str, name: &str, env: Option<&str>) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let resp = client.group_secret_read_value(group, name, env).await?;
|
|
match resp.value {
|
|
serde_json::Value::String(s) => println!("{s}"),
|
|
other => println!("{}", serde_json::to_string_pretty(&other)?),
|
|
}
|
|
Ok(())
|
|
}
|