feat(cli): plan warnings, apply confirm + blast-radius gate, files --group, unified owner-XOR

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>
This commit is contained in:
MechaCat02
2026-07-11 23:47:59 +02:00
parent 5e1bda1f32
commit a3c4267f6c
12 changed files with 289 additions and 52 deletions

View File

@@ -7,7 +7,7 @@
use std::io::Write;
use anyhow::{bail, Result};
use anyhow::Result;
use crate::client::Client;
use crate::config;
@@ -22,11 +22,10 @@ pub async fn ls(
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = match (app, group) {
(Some(a), None) => client.kv_list(a, collection, limit).await?,
let page = match crate::cmds::require_one_owner(app, group)? {
crate::cmds::OwnerRef::App(a) => client.kv_list(a, collection, limit).await?,
// §11.6 M4: a group's shared-KV collection.
(None, Some(g)) => client.group_kv_list(g, collection, limit).await?,
_ => bail!("provide exactly one of --app or --group"),
crate::cmds::OwnerRef::Group(g) => client.group_kv_list(g, collection, limit).await?,
};
let mut table = Table::new(["key"]);
for k in page.keys {
@@ -50,10 +49,9 @@ pub async fn get(
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let value = match (app, group) {
(Some(a), None) => client.kv_get(a, collection, key).await?,
(None, Some(g)) => client.group_kv_get(g, collection, key).await?,
_ => bail!("provide exactly one of --app or --group"),
let value = match crate::cmds::require_one_owner(app, group)? {
crate::cmds::OwnerRef::App(a) => client.kv_get(a, collection, key).await?,
crate::cmds::OwnerRef::Group(g) => client.group_kv_get(g, collection, key).await?,
};
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());