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

@@ -1139,6 +1139,50 @@ impl Client {
}
}
/// §11.6 M4: a group's shared-files collection (read-only admin surface).
/// `GET /api/v1/admin/groups/{id_or_slug}/files?collection=&limit=`
pub async fn group_files_list(
&self,
group: &str,
collection: &str,
limit: u32,
) -> Result<ListFilesResponse> {
let (group, collection) = (seg(group), seg(collection));
let resp = self
.request(
Method::GET,
&format!(
"/api/v1/admin/groups/{group}/files?collection={collection}&limit={limit}"
),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}/files/{collection}/{file_id}` —
/// streams a shared file's raw bytes (download).
pub async fn group_files_get_bytes(
&self,
group: &str,
collection: &str,
file_id: &str,
) -> Result<Vec<u8>> {
let (group, collection, file_id) = (seg(group), seg(collection), seg(file_id));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/groups/{group}/files/{collection}/{file_id}"),
)
.send()
.await?;
if resp.status().is_success() {
Ok(resp.bytes().await.context("reading file bytes")?.to_vec())
} else {
Err(server_error(resp).await)
}
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}`
pub async fn files_delete(&self, app: &str, collection: &str, file_id: &str) -> Result<()> {
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
@@ -1585,6 +1629,10 @@ pub struct PlanDto {
/// §3 M3: envs the governing project marks confirm-required (need `--approve`).
#[serde(default)]
pub approvals_required: Vec<String>,
/// Desired-state warnings `apply` would emit, previewed at plan (disabled
/// binding, unreachable endpoint, dangling suppress).
#[serde(default)]
pub warnings: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -1653,6 +1701,9 @@ pub struct NodePlanDto {
/// names the server + manifest parents).
#[serde(default)]
pub structure: Option<StructurePreviewDto>,
/// Desired-state warnings this node's apply would emit, previewed at plan.
#[serde(default)]
pub warnings: Vec<String>,
}
#[derive(Debug, Deserialize)]

View File

@@ -8,7 +8,7 @@ use std::path::Path;
use anyhow::{Context, Result};
use crate::client::{Client, NodeKind};
use crate::client::{Client, NodeKind, NodePlanDto, PlanDto};
use crate::cmds::plan::build_bundle;
use crate::config;
use crate::manifest::{Manifest, ManifestProject};
@@ -62,6 +62,24 @@ pub async fn run(
.map(|l| l.state_token)
};
// First-run guard: with no reviewed `.picloud/` plan, `apply` would mutate
// with zero preview. Fetch a plan, show the change + blast-radius summary,
// and confirm (interactive) or refuse a large cross-repo blast radius in CI.
// `--yes`/`--force` opt out.
if expected_token.is_none() && !yes && !force {
let plan = client
.plan_node(kind, &slug, &bundle, governing.as_ref())
.await?;
let changes = plan_change_count(&plan);
let blast = plan
.ownership
.as_ref()
.map_or(0, |o| o.blast_radius.iter().map(|b| b.apps).sum());
if changes > 0 || blast > 0 {
confirm_first_apply(&slug, changes, blast, &plan.warnings)?;
}
}
let report = client
.apply_node(
kind,
@@ -170,6 +188,25 @@ pub async fn run_tree(
.map(|l| l.state_token)
};
// First-run guard (see `run`): no reviewed plan → preview + confirm the whole
// tree's change count and cross-repo blast radius before mutating.
if expected_token.is_none() && !yes && !force {
let plan = client.plan_tree(&bundle, project.as_ref()).await?;
let changes: usize = plan.nodes.iter().map(node_change_count).sum();
let blast: u32 = plan
.nodes
.iter()
.filter_map(|n| n.ownership.as_ref())
.flat_map(|o| o.blast_radius.iter())
.map(|b| b.apps)
.sum();
let warnings: Vec<String> = plan.nodes.iter().flat_map(|n| n.warnings.clone()).collect();
if changes > 0 || blast > 0 {
let target = format!("{node_count} node(s) under {}", dir.display());
confirm_first_apply(&target, changes, blast, &warnings)?;
}
}
let report = client
.apply_tree(
&bundle,
@@ -284,6 +321,73 @@ fn require_env_approval(
Ok(())
}
/// A cross-repo blast radius at or above this many other-project apps is refused
/// non-interactively without `--yes` (§4.2 "this changes N apps — confirm").
const BLAST_RADIUS_CONFIRM_THRESHOLD: u32 = 10;
fn plan_change_count(plan: &PlanDto) -> usize {
plan.scripts.len()
+ plan.routes.len()
+ plan.triggers.len()
+ plan.secrets.len()
+ plan.vars.len()
+ plan.extension_points.len()
+ plan.collections.len()
+ plan.suppressions.len()
}
fn node_change_count(n: &NodePlanDto) -> usize {
n.scripts.len()
+ n.routes.len()
+ n.triggers.len()
+ n.secrets.len()
+ n.vars.len()
+ n.extension_points.len()
+ n.collections.len()
+ n.suppressions.len()
}
/// First-run guard: `pic apply` with no reviewed `.picloud/` plan mutates with
/// zero preview. When none was recorded (and not `--yes`/`--force`), the caller
/// fetches a plan and passes its change count + cross-repo blast radius here. On
/// a TTY we show the summary and prompt; non-interactively we allow a small
/// change but REFUSE a large blast radius without an explicit `--yes` (so a
/// wide-reaching change can't slip through CI unreviewed).
fn confirm_first_apply(
target: &str,
changes: usize,
blast_apps: u32,
warnings: &[String],
) -> Result<()> {
if !std::io::stdin().is_terminal() {
if blast_apps >= BLAST_RADIUS_CONFIRM_THRESHOLD {
anyhow::bail!(
"refusing to apply `{target}` non-interactively: no reviewed plan and this \
change fans out to {blast_apps} app(s) across other projects. Review with \
`pic plan`, then re-run with `--yes`."
);
}
return Ok(());
}
eprintln!("no reviewed plan for `{target}`; this apply will make {changes} change(s).");
if blast_apps > 0 {
eprintln!(" cross-repo blast radius: {blast_apps} app(s) in other project(s).");
}
for w in warnings {
eprintln!(" warning: {w}");
}
eprint!("Continue? [y/N] ");
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read confirmation")?;
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
anyhow::bail!("aborted");
}
Ok(())
}
fn confirm_prune(slug: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
anyhow::bail!(

View File

@@ -4,17 +4,17 @@
//! for a group, its own declared names. Authoring is declarative only (the
//! manifest `extension_points = [...]`).
use anyhow::{bail, Result};
use anyhow::Result;
use crate::client::{Client, NodeKind};
use crate::cmds::OwnerRef;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
let (kind, ident) = match (app, group) {
(Some(a), None) => (NodeKind::App, a),
(None, Some(g)) => (NodeKind::Group, g),
_ => bail!("`extension-points ls` requires exactly one of --app or --group"),
let (kind, ident) = match crate::cmds::require_one_owner(app, group)? {
OwnerRef::App(a) => (NodeKind::App, a),
OwnerRef::Group(g) => (NodeKind::Group, g),
};
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;

View File

@@ -13,10 +13,20 @@ use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
pub async fn ls(
app: Option<&str>,
group: Option<&str>,
collection: &str,
limit: u32,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.files_list(app, collection, limit).await?;
let page = match crate::cmds::require_one_owner(app, group)? {
crate::cmds::OwnerRef::App(a) => client.files_list(a, collection, limit).await?,
// §11.6 M4: a group's shared-files collection.
crate::cmds::OwnerRef::Group(g) => client.group_files_list(g, collection, limit).await?,
};
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
for f in page.files {
table.row([
@@ -38,11 +48,22 @@ pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Re
}
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
/// streams raw bytes to stdout (pipe to a file or `xxd`).
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
/// streams raw bytes to stdout (pipe to a file or `xxd`). `--app` or `--group`.
pub async fn get(
app: Option<&str>,
group: Option<&str>,
collection: &str,
file_id: &str,
out: Option<&Path>,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let bytes = client.files_get_bytes(app, collection, file_id).await?;
let bytes = match crate::cmds::require_one_owner(app, group)? {
crate::cmds::OwnerRef::App(a) => client.files_get_bytes(a, collection, file_id).await?,
crate::cmds::OwnerRef::Group(g) => {
client.group_files_get_bytes(g, collection, file_id).await?
}
};
match out {
Some(path) => {
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;

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());

View File

@@ -1,3 +1,5 @@
use anyhow::{bail, Result};
pub mod admins;
pub mod api_keys;
pub mod apply;
@@ -28,3 +30,24 @@ pub mod triggers;
pub mod users;
pub mod vars;
pub mod whoami;
/// A resolved owner reference for a command that targets EITHER an app or a
/// group (the `--app` / `--group` XOR). See [`require_one_owner`].
#[derive(Debug)]
pub enum OwnerRef<'a> {
App(&'a str),
Group(&'a str),
}
/// Enforce the `--app` / `--group` XOR with ONE canonical message across every
/// owner-polymorphic command (kv, files, vars, secrets, scripts, triggers,
/// routes, suppress, extension-points, …), so the error reads the same
/// everywhere instead of five slightly different wordings.
pub fn require_one_owner<'a>(app: Option<&'a str>, group: Option<&'a str>) -> Result<OwnerRef<'a>> {
match (app, group) {
(Some(a), None) => Ok(OwnerRef::App(a)),
(None, Some(g)) => Ok(OwnerRef::Group(g)),
(Some(_), Some(_)) => bail!("provide exactly one of --app or --group, not both"),
(None, None) => bail!("provide exactly one of --app or --group"),
}
}

View File

@@ -127,6 +127,15 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
]);
}
}
for w in &n.warnings {
table.row([
node.clone(),
"warning".to_string(),
String::new(),
w.clone(),
String::new(),
]);
}
}
table.print(mode);
render_approvals(&plan.approvals_required, mode);
@@ -143,6 +152,18 @@ fn render_approvals(approvals_required: &[String], mode: OutputMode) {
}
}
/// Surface the desired-state warnings `apply` would emit, so `pic plan` is a
/// faithful preview for CI (disabled binding, unreachable endpoint, dangling
/// suppress). JSON callers read them off the `warnings` field.
fn render_warnings(warnings: &[String], mode: OutputMode) {
if mode != OutputMode::Json && !warnings.is_empty() {
eprintln!("\nwarnings:");
for w in warnings {
eprintln!(" - {w}");
}
}
}
/// Assemble the wire bundle: scripts carry inlined source (read from
/// their `file`), routes pass through, triggers flatten into a tagged
/// array, secrets are names only.
@@ -277,4 +298,5 @@ fn render(plan: &PlanDto, mode: OutputMode) {
}
table.print(mode);
render_approvals(&plan.approvals_required, mode);
render_warnings(&plan.warnings, mode);
}

View File

@@ -160,7 +160,9 @@ pub async fn deploy(
}
}
(Some(_), Some(_)) | (None, None) => {
return Err(anyhow!("deploy requires exactly one of --app or --group"));
// Reuse the shared XOR message (both-or-neither always errors here).
return Err(crate::cmds::require_one_owner(app_ident, group_ident)
.expect_err("both-or-neither must error"));
}
};
// Emit the script object so `--output json` callers can capture the

View File

@@ -15,14 +15,13 @@ use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::output::{OutputMode, Table};
/// Resolve the `--group`/`--app` pair into exactly one owner.
/// 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>> {
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")),
}
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(

View File

@@ -4,7 +4,7 @@
//! is declarative via the `[suppress]` block (`triggers = [...]` handler script
//! names, `routes = [...]` paths).
use anyhow::{bail, Result};
use anyhow::Result;
use crate::client::Client;
use crate::config;
@@ -13,10 +13,9 @@ use crate::output::{OutputMode, Table};
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let items = match (app, group) {
(Some(a), None) => client.suppressions_list(a).await?,
(None, Some(g)) => client.group_suppressions_list(g).await?,
_ => bail!("provide exactly one of --app or --group"),
let items = match crate::cmds::require_one_owner(app, group)? {
crate::cmds::OwnerRef::App(a) => client.suppressions_list(a).await?,
crate::cmds::OwnerRef::Group(g) => client.group_suppressions_list(g).await?,
};
let mut table = Table::new(["kind", "reference"]);

View File

@@ -11,14 +11,13 @@ use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::output::{OutputMode, Table};
/// Resolve the `--group`/`--app` pair into exactly one owner.
/// 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>> {
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")),
}
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>, mode: OutputMode) -> Result<()> {

View File

@@ -249,7 +249,9 @@ struct ApplyArgs {
#[arg(long)]
yes: bool,
/// Skip the bound-plan staleness check (apply even if the app changed
/// since the last `pic plan`).
/// since the last `pic plan`). Also skips the first-run preview + blast-
/// radius confirmation prompt (like `--yes`), so a scripted `--force` apply
/// never blocks on a TTY question.
#[arg(long)]
force: bool,
/// §7 multi-repo ownership: reassign a group node owned by another
@@ -399,19 +401,24 @@ enum MembersCmd {
#[derive(Subcommand)]
enum FilesCmd {
/// List files in a collection.
/// List files in a collection. Exactly one of `--app` (per-app files) or
/// `--group` (a group's §11.6 shared files).
Ls {
#[arg(long, conflicts_with = "group")]
app: Option<String>,
#[arg(long)]
app: String,
group: Option<String>,
#[arg(long)]
collection: String,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Download a file's bytes (to `--out <path>` or stdout).
/// Download a file's bytes (to `--out <path>` or stdout). `--app` or `--group`.
Get {
#[arg(long, conflicts_with = "group")]
app: Option<String>,
#[arg(long)]
app: String,
group: Option<String>,
#[arg(long)]
collection: String,
#[arg(long = "id")]
@@ -419,7 +426,8 @@ enum FilesCmd {
#[arg(long)]
out: Option<PathBuf>,
},
/// Delete a file.
/// Delete a file. Per-app only — shared-collection blobs are not
/// operator-deletable (writes go through scripts).
Rm {
#[arg(long)]
app: String,
@@ -1790,10 +1798,10 @@ async fn main() -> ExitCode {
} => cmds::admins::rm(&id).await,
Cmd::Triggers {
cmd: TriggersCmd::Ls { app, group },
} => match (app, group) {
(_, Some(group)) => cmds::triggers::ls_group(&group, mode).await,
(Some(app), None) => cmds::triggers::ls(&app, mode).await,
(None, None) => Err(anyhow::anyhow!("provide --app or --group")),
} => match cmds::require_one_owner(app.as_deref(), group.as_deref()) {
Ok(cmds::OwnerRef::App(a)) => cmds::triggers::ls(a, mode).await,
Ok(cmds::OwnerRef::Group(g)) => cmds::triggers::ls_group(g, mode).await,
Err(e) => Err(e),
},
Cmd::Triggers {
cmd: TriggersCmd::Rm { app, trigger_id },
@@ -2101,19 +2109,30 @@ async fn main() -> ExitCode {
cmd:
FilesCmd::Ls {
app,
group,
collection,
limit,
},
} => cmds::files::ls(&app, &collection, limit, mode).await,
} => cmds::files::ls(app.as_deref(), group.as_deref(), &collection, limit, mode).await,
Cmd::Files {
cmd:
FilesCmd::Get {
app,
group,
collection,
file_id,
out,
},
} => cmds::files::get(&app, &collection, &file_id, out.as_deref()).await,
} => {
cmds::files::get(
app.as_deref(),
group.as_deref(),
&collection,
&file_id,
out.as_deref(),
)
.await
}
Cmd::Files {
cmd:
FilesCmd::Rm {