diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 72b00eb..a8daa11 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -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 { + 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> { + 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, + /// Desired-state warnings `apply` would emit, previewed at plan (disabled + /// binding, unreachable endpoint, dangling suppress). + #[serde(default)] + pub warnings: Vec, } #[derive(Debug, Deserialize)] @@ -1653,6 +1701,9 @@ pub struct NodePlanDto { /// names the server + manifest parents). #[serde(default)] pub structure: Option, + /// Desired-state warnings this node's apply would emit, previewed at plan. + #[serde(default)] + pub warnings: Vec, } #[derive(Debug, Deserialize)] diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index f526938..2aaf37a 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -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 = 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!( diff --git a/crates/picloud-cli/src/cmds/extension_points.rs b/crates/picloud-cli/src/cmds/extension_points.rs index 1c806d9..a590ed2 100644 --- a/crates/picloud-cli/src/cmds/extension_points.rs +++ b/crates/picloud-cli/src/cmds/extension_points.rs @@ -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)?; diff --git a/crates/picloud-cli/src/cmds/files.rs b/crates/picloud-cli/src/cmds/files.rs index c3a649e..ee31ff8 100644 --- a/crates/picloud-cli/src/cmds/files.rs +++ b/crates/picloud-cli/src/cmds/files.rs @@ -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 ` 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()))?; diff --git a/crates/picloud-cli/src/cmds/kv.rs b/crates/picloud-cli/src/cmds/kv.rs index fb04237..af818e2 100644 --- a/crates/picloud-cli/src/cmds/kv.rs +++ b/crates/picloud-cli/src/cmds/kv.rs @@ -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()); diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index e42e176..af03e7a 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -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> { + 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"), + } +} diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index b4536fe..291c7cd 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -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); } diff --git a/crates/picloud-cli/src/cmds/scripts.rs b/crates/picloud-cli/src/cmds/scripts.rs index dc204e3..f78bd19 100644 --- a/crates/picloud-cli/src/cmds/scripts.rs +++ b/crates/picloud-cli/src/cmds/scripts.rs @@ -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 diff --git a/crates/picloud-cli/src/cmds/secrets.rs b/crates/picloud-cli/src/cmds/secrets.rs index b6129b2..c6c2638 100644 --- a/crates/picloud-cli/src/cmds/secrets.rs +++ b/crates/picloud-cli/src/cmds/secrets.rs @@ -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> { - 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( diff --git a/crates/picloud-cli/src/cmds/suppress.rs b/crates/picloud-cli/src/cmds/suppress.rs index 2bef3a0..f906405 100644 --- a/crates/picloud-cli/src/cmds/suppress.rs +++ b/crates/picloud-cli/src/cmds/suppress.rs @@ -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"]); diff --git a/crates/picloud-cli/src/cmds/vars.rs b/crates/picloud-cli/src/cmds/vars.rs index f6d42d2..f65a8e7 100644 --- a/crates/picloud-cli/src/cmds/vars.rs +++ b/crates/picloud-cli/src/cmds/vars.rs @@ -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> { - 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<()> { diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 2f50aa0..104c130 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -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, #[arg(long)] - app: String, + group: Option, #[arg(long)] collection: String, #[arg(long, default_value_t = 100)] limit: u32, }, - /// Download a file's bytes (to `--out ` or stdout). + /// Download a file's bytes (to `--out ` or stdout). `--app` or `--group`. Get { + #[arg(long, conflicts_with = "group")] + app: Option, #[arg(long)] - app: String, + group: Option, #[arg(long)] collection: String, #[arg(long = "id")] @@ -419,7 +426,8 @@ enum FilesCmd { #[arg(long)] out: Option, }, - /// 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 {