feat(cli): pic secrets --group, value read, and effective vars/config
Mirrors `pic vars` on the secrets surface: `pic secrets ls/set/rm` take an `--app`/`--group` owner selector (exactly-one) with `--env` for group secrets. Adds `pic secrets read --group <g> <name> [--env]` — the only command that reveals a secret value, hitting the group-gated value endpoint. `pic config --effective` now folds in the resolved vars section (value + owner + scope) and an `--explain` provenance view, alongside the existing masked-secrets cross-reference, via `GET /config/effective`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -657,41 +657,77 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.send()
|
||||
.await?;
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names +
|
||||
/// last-modified for the owner. Values never travel on this path. `env`
|
||||
/// is only meaningful for group owners (app secrets are env-agnostic).
|
||||
pub async fn secrets_list(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
env: Option<&str>,
|
||||
) -> Result<SecretListDto> {
|
||||
let mut path = format!("{}/secrets", owner.base_path());
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path — only `app` needs encoding.
|
||||
let app = seg(app);
|
||||
/// `POST /api/v1/admin/{apps,groups}/{id}/secrets`. `env` rides in the
|
||||
/// body and is only honored by group owners.
|
||||
pub async fn secrets_set(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
name: &str,
|
||||
value: serde_json::Value,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path.
|
||||
let mut body = serde_json::json!({ "name": name, "value": value });
|
||||
if let Some(env) = env {
|
||||
body["env"] = serde_json::Value::String(env.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.json(&serde_json::json!({ "name": name, "value": value }))
|
||||
.request(Method::POST, &format!("{}/secrets", owner.base_path()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
|
||||
let (app, name) = (seg(app), seg(name));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/secrets/{name}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
/// `DELETE /api/v1/admin/{apps,groups}/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut path = format!("{}/secrets/{}", owner.base_path(), seg(name));
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id}/secrets/{name}/value` — the ONLY path
|
||||
/// that returns a decrypted secret value. Gated server-side at the owning
|
||||
/// group; there is no app-secret equivalent by design.
|
||||
pub async fn group_secret_read_value(
|
||||
&self,
|
||||
group: &str,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<SecretValueDto> {
|
||||
let (group, name) = (seg(group), seg(name));
|
||||
let mut path = format!("/api/v1/admin/groups/{group}/secrets/{name}/value");
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- vars (Phase 3 config) ----------
|
||||
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/vars` — the owner's OWN vars
|
||||
@@ -740,6 +776,21 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/config/effective` — the app's resolved
|
||||
/// (group-inherited) vars plus masked secret statuses, each annotated with
|
||||
/// the owner that won and the layers it merged from (§4.6).
|
||||
pub async fn config_effective(&self, app: &str) -> Result<EffectiveConfigDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/config/effective"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- domains ----------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/domains`
|
||||
@@ -1484,9 +1535,72 @@ pub struct SecretListDto {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretItemDto {
|
||||
pub name: String,
|
||||
/// Env scope. Only meaningful (and populated) for group owners; the server
|
||||
/// omits it for app secrets, so default to `*` when absent.
|
||||
#[serde(default = "default_env")]
|
||||
pub env: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_env() -> String {
|
||||
"*".to_string()
|
||||
}
|
||||
|
||||
/// Plaintext value of a single group secret — the response of the gated
|
||||
/// `.../secrets/{name}/value` read. The decrypted value is arbitrary JSON.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretValueDto {
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub env: String,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
// --- effective config (`/config/effective`) ---
|
||||
|
||||
/// The owner (app or group) a resolved layer belongs to, with its distance
|
||||
/// from the app in the inheritance chain (`depth` 0 = the app itself).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveOwnerDto {
|
||||
pub kind: String,
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub depth: u32,
|
||||
}
|
||||
|
||||
/// One layer a resolved var merged from, deepest-first as the server returns it.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergedFromDto {
|
||||
pub depth: u32,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveVarDto {
|
||||
pub value: serde_json::Value,
|
||||
pub owner: EffectiveOwnerDto,
|
||||
pub scope: String,
|
||||
#[serde(default)]
|
||||
pub merged_from: Vec<MergedFromDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveSecretDto {
|
||||
#[allow(dead_code)]
|
||||
pub status: String,
|
||||
pub owner: EffectiveOwnerDto,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveConfigDto {
|
||||
#[serde(default)]
|
||||
pub vars: std::collections::BTreeMap<String, EffectiveVarDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: std::collections::BTreeMap<String, EffectiveSecretDto>,
|
||||
}
|
||||
|
||||
/// Per-script runtime config the CLI can now set (G3). All optional — an
|
||||
/// unset field is omitted so the server applies its own default (and the
|
||||
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
|
||||
|
||||
Reference in New Issue
Block a user