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).
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
//! `pic config --effective` — read-only view of an app's resolved
|
||||
//! configuration, with secret values masked (§4.6).
|
||||
//!
|
||||
//! Single-app today: "config" means the app's secrets, cross-referenced
|
||||
//! against the manifest so an operator can see at a glance which declared
|
||||
//! secrets are still unset and which live secrets aren't declared. Values are
|
||||
//! never fetched or shown — only `<set>` / `<unset>`. The command is shaped to
|
||||
//! grow an `--explain` mode and inherited `vars` once groups/vars land (the
|
||||
//! Phase-3 multi-level resolution).
|
||||
//! Two sections:
|
||||
//! * `vars` — the resolved (group-inherited) config vars, each `key = value`
|
||||
//! annotated with the owner that won (kind + depth) and its scope. Pass
|
||||
//! `--explain` to also dump each var's `merged_from` provenance — the
|
||||
//! ordered (depth, scope) layers that fed the resolution.
|
||||
//! * `secrets` — masked statuses, cross-referenced against the manifest so an
|
||||
//! operator can see which declared secrets are still unset and which live
|
||||
//! secrets aren't declared. Values are never fetched or shown here; the
|
||||
//! server reports only `<set>` / `<unset>` plus the owning layer.
|
||||
//!
|
||||
//! The vars + masked-secret owner info comes from the `/config/effective`
|
||||
//! endpoint; the manifest is only used to flag `declared`/`unset` drift.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, EffectiveOwnerDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`).
|
||||
fn owner_label(owner: &EffectiveOwnerDto) -> String {
|
||||
format!("{}@{}", owner.kind, owner.depth)
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
effective: bool,
|
||||
explain: bool,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
@@ -34,24 +46,54 @@ pub async fn run(
|
||||
// base slug — when an overlay re-points slug/secrets.
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
|
||||
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
|
||||
let on_server: BTreeSet<String> = client
|
||||
.secrets_list(&manifest.app.slug)
|
||||
.await?
|
||||
.secrets
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
let eff = client.config_effective(&manifest.app.slug).await?;
|
||||
|
||||
let mut table = Table::new(["secret", "value", "status"]);
|
||||
// --- vars: the resolved view, with winning owner + provenance. ---
|
||||
let mut vars_table = Table::new(["key", "value", "owner", "scope"]);
|
||||
for (key, var) in &eff.vars {
|
||||
vars_table.row([
|
||||
key.clone(),
|
||||
var.value.to_string(),
|
||||
owner_label(&var.owner),
|
||||
var.scope.clone(),
|
||||
]);
|
||||
}
|
||||
vars_table.print(mode);
|
||||
|
||||
if explain {
|
||||
// Provenance: the (depth, scope) layers each resolved key merged from.
|
||||
let mut prov = Table::new(["key", "depth", "scope"]);
|
||||
for (key, var) in &eff.vars {
|
||||
for layer in &var.merged_from {
|
||||
prov.row([key.clone(), layer.depth.to_string(), layer.scope.clone()]);
|
||||
}
|
||||
}
|
||||
prov.print(mode);
|
||||
}
|
||||
|
||||
// --- secrets: masked status, folding manifest drift + owning layer. ---
|
||||
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
|
||||
let on_server: BTreeSet<String> = eff.secrets.keys().cloned().collect();
|
||||
|
||||
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
|
||||
for name in declared.union(&on_server) {
|
||||
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
||||
(true, true) => ("<set>", "managed"),
|
||||
(true, false) => ("<unset>", "declared, not pushed — `pic secret set`"),
|
||||
(true, false) => ("<unset>", "declared, not pushed — `pic secrets set`"),
|
||||
(false, true) => ("<set>", "on server, not in manifest"),
|
||||
(false, false) => unreachable!("name came from one of the two sets"),
|
||||
};
|
||||
table.row([name.clone(), value.to_string(), status.to_string()]);
|
||||
let (owner, scope) = match eff.secrets.get(name) {
|
||||
Some(s) => (owner_label(&s.owner), s.scope.clone()),
|
||||
None => ("-".to_string(), "-".to_string()),
|
||||
};
|
||||
table.row([
|
||||
name.clone(),
|
||||
value.to_string(),
|
||||
status.to_string(),
|
||||
owner,
|
||||
scope,
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
|
||||
@@ -14,7 +14,7 @@ use anyhow::{Context, Result};
|
||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
@@ -45,7 +45,10 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
||||
let secrets = client.secrets_list(app_ident).await?.secrets;
|
||||
let secrets = client
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
|
||||
let name_by_id: HashMap<ScriptId, String> =
|
||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
//! `pic secrets` subcommands: `ls`, `set`, `rm`.
|
||||
//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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;
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
/// Resolve the `--group`/`--app` pair into exactly one owner.
|
||||
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||
match (group, app) {
|
||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
||||
}
|
||||
}
|
||||
|
||||
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(app).await?;
|
||||
let mut table = Table::new(["name", "updated_at"]);
|
||||
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.updated_at.to_rfc3339()]);
|
||||
table.row([s.name, s.env, s.updated_at.to_rfc3339()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
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();
|
||||
@@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
} else {
|
||||
serde_json::Value::String(trimmed.to_string())
|
||||
};
|
||||
client.secrets_set(app, name, value).await?;
|
||||
client.secrets_set(owner, name, value, env).await?;
|
||||
println!("Set secret {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, name: &str) -> Result<()> {
|
||||
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(app, name).await?;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -267,6 +267,10 @@ struct ConfigArgs {
|
||||
/// Show the effective (resolved) config with secrets masked.
|
||||
#[arg(long)]
|
||||
effective: bool,
|
||||
/// With `--effective`, also print each resolved var's `merged_from`
|
||||
/// provenance — the ordered (depth, scope) layers it merged from.
|
||||
#[arg(long)]
|
||||
explain: bool,
|
||||
/// Resolve against the `picloud.<env>.toml` overlay (per-env slug +
|
||||
/// secrets), matching `pic plan --env` / `pic apply --env`.
|
||||
#[arg(long)]
|
||||
@@ -1142,31 +1146,67 @@ enum DeadLettersCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SecretsCmd {
|
||||
/// List secret names + last-modified for an app. Values never
|
||||
/// leave the server.
|
||||
/// List secret names + last-modified for the owner. Values never
|
||||
/// leave the server. `--env` filters group secrets (app secrets are
|
||||
/// env-agnostic; the `env` column shows the scope for groups).
|
||||
Ls {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Environment scope (group owners only).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
|
||||
/// Set a secret. Reads the value from stdin (the only safe
|
||||
/// channel — inline values would land in shell history). Pipe
|
||||
/// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`.
|
||||
/// For group owners, `--env` scopes the secret.
|
||||
Set {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
name: String,
|
||||
/// Environment scope (group owners only).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Treat stdin as raw JSON (numbers, maps, …) instead of a
|
||||
/// string literal.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Delete a secret by name.
|
||||
/// Delete a secret by name (optionally env-scoped for groups).
|
||||
Rm {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
name: String,
|
||||
/// Environment scope (group owners only).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
|
||||
/// 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 — hence `--group` only, no `--app`.
|
||||
Read {
|
||||
/// Owning group (slug or id).
|
||||
#[arg(long)]
|
||||
group: String,
|
||||
name: String,
|
||||
/// Environment scope.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1293,7 +1333,14 @@ async fn main() -> ExitCode {
|
||||
Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
|
||||
Cmd::Config(args) => {
|
||||
cmds::config::run(&args.file, args.effective, args.env.as_deref(), mode).await
|
||||
cmds::config::run(
|
||||
&args.file,
|
||||
args.effective,
|
||||
args.explain,
|
||||
args.env.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Init(args) => cmds::init::run(
|
||||
&args.dir,
|
||||
@@ -1796,14 +1843,39 @@ async fn main() -> ExitCode {
|
||||
cmd: DeadLettersCmd::Resolve { app, dl_id, reason },
|
||||
} => cmds::dead_letters::resolve(&app, &dl_id, &reason).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Ls { app },
|
||||
} => cmds::secrets::ls(&app, mode).await,
|
||||
cmd: SecretsCmd::Ls { group, app, env },
|
||||
} => cmds::secrets::ls(group.as_deref(), app.as_deref(), env.as_deref(), mode).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Set { app, name, json },
|
||||
} => cmds::secrets::set(&app, &name, json).await,
|
||||
cmd:
|
||||
SecretsCmd::Set {
|
||||
group,
|
||||
app,
|
||||
name,
|
||||
env,
|
||||
json,
|
||||
},
|
||||
} => {
|
||||
cmds::secrets::set(
|
||||
group.as_deref(),
|
||||
app.as_deref(),
|
||||
&name,
|
||||
env.as_deref(),
|
||||
json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Rm { app, name },
|
||||
} => cmds::secrets::rm(&app, &name).await,
|
||||
cmd:
|
||||
SecretsCmd::Rm {
|
||||
group,
|
||||
app,
|
||||
name,
|
||||
env,
|
||||
},
|
||||
} => cmds::secrets::rm(group.as_deref(), app.as_deref(), &name, env.as_deref()).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Read { group, name, env },
|
||||
} => cmds::secrets::read(&group, &name, env.as_deref()).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Ls { app },
|
||||
} => cmds::members::ls(&app, mode).await,
|
||||
|
||||
Reference in New Issue
Block a user