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:
@@ -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)]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user