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:
MechaCat02
2026-06-24 22:00:32 +02:00
parent c914758c09
commit 30441549d5
5 changed files with 347 additions and 70 deletions

View File

@@ -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,