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}`
|
/// `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<()> {
|
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));
|
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`).
|
/// §3 M3: envs the governing project marks confirm-required (need `--approve`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub approvals_required: Vec<String>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1653,6 +1701,9 @@ pub struct NodePlanDto {
|
|||||||
/// names the server + manifest parents).
|
/// names the server + manifest parents).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub structure: Option<StructurePreviewDto>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use crate::client::{Client, NodeKind};
|
use crate::client::{Client, NodeKind, NodePlanDto, PlanDto};
|
||||||
use crate::cmds::plan::build_bundle;
|
use crate::cmds::plan::build_bundle;
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::manifest::{Manifest, ManifestProject};
|
use crate::manifest::{Manifest, ManifestProject};
|
||||||
@@ -62,6 +62,24 @@ pub async fn run(
|
|||||||
.map(|l| l.state_token)
|
.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
|
let report = client
|
||||||
.apply_node(
|
.apply_node(
|
||||||
kind,
|
kind,
|
||||||
@@ -170,6 +188,25 @@ pub async fn run_tree(
|
|||||||
.map(|l| l.state_token)
|
.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
|
let report = client
|
||||||
.apply_tree(
|
.apply_tree(
|
||||||
&bundle,
|
&bundle,
|
||||||
@@ -284,6 +321,73 @@ fn require_env_approval(
|
|||||||
Ok(())
|
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<()> {
|
fn confirm_prune(slug: &str) -> Result<()> {
|
||||||
if !std::io::stdin().is_terminal() {
|
if !std::io::stdin().is_terminal() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
@@ -4,17 +4,17 @@
|
|||||||
//! for a group, its own declared names. Authoring is declarative only (the
|
//! for a group, its own declared names. Authoring is declarative only (the
|
||||||
//! manifest `extension_points = [...]`).
|
//! manifest `extension_points = [...]`).
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::client::{Client, NodeKind};
|
use crate::client::{Client, NodeKind};
|
||||||
|
use crate::cmds::OwnerRef;
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::output::{OutputMode, Table};
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
let (kind, ident) = match (app, group) {
|
let (kind, ident) = match crate::cmds::require_one_owner(app, group)? {
|
||||||
(Some(a), None) => (NodeKind::App, a),
|
OwnerRef::App(a) => (NodeKind::App, a),
|
||||||
(None, Some(g)) => (NodeKind::Group, g),
|
OwnerRef::Group(g) => (NodeKind::Group, g),
|
||||||
_ => bail!("`extension-points ls` requires exactly one of --app or --group"),
|
|
||||||
};
|
};
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|||||||
@@ -13,10 +13,20 @@ use crate::client::Client;
|
|||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::output::{OutputMode, Table};
|
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 creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
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"]);
|
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
|
||||||
for f in page.files {
|
for f in page.files {
|
||||||
table.row([
|
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
|
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
|
||||||
/// streams raw bytes to stdout (pipe to a file or `xxd`).
|
/// streams raw bytes to stdout (pipe to a file or `xxd`). `--app` or `--group`.
|
||||||
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
|
pub async fn get(
|
||||||
|
app: Option<&str>,
|
||||||
|
group: Option<&str>,
|
||||||
|
collection: &str,
|
||||||
|
file_id: &str,
|
||||||
|
out: Option<&Path>,
|
||||||
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
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 {
|
match out {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
|
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::config;
|
use crate::config;
|
||||||
@@ -22,11 +22,10 @@ pub async fn ls(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let page = match (app, group) {
|
let page = match crate::cmds::require_one_owner(app, group)? {
|
||||||
(Some(a), None) => client.kv_list(a, collection, limit).await?,
|
crate::cmds::OwnerRef::App(a) => client.kv_list(a, collection, limit).await?,
|
||||||
// §11.6 M4: a group's shared-KV collection.
|
// §11.6 M4: a group's shared-KV collection.
|
||||||
(None, Some(g)) => client.group_kv_list(g, collection, limit).await?,
|
crate::cmds::OwnerRef::Group(g) => client.group_kv_list(g, collection, limit).await?,
|
||||||
_ => bail!("provide exactly one of --app or --group"),
|
|
||||||
};
|
};
|
||||||
let mut table = Table::new(["key"]);
|
let mut table = Table::new(["key"]);
|
||||||
for k in page.keys {
|
for k in page.keys {
|
||||||
@@ -50,10 +49,9 @@ pub async fn get(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let value = match (app, group) {
|
let value = match crate::cmds::require_one_owner(app, group)? {
|
||||||
(Some(a), None) => client.kv_get(a, collection, key).await?,
|
crate::cmds::OwnerRef::App(a) => client.kv_get(a, collection, key).await?,
|
||||||
(None, Some(g)) => client.group_kv_get(g, collection, key).await?,
|
crate::cmds::OwnerRef::Group(g) => client.group_kv_get(g, collection, key).await?,
|
||||||
_ => bail!("provide exactly one of --app or --group"),
|
|
||||||
};
|
};
|
||||||
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
|
// 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());
|
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 admins;
|
||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod apply;
|
pub mod apply;
|
||||||
@@ -28,3 +30,24 @@ pub mod triggers;
|
|||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod vars;
|
pub mod vars;
|
||||||
pub mod whoami;
|
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);
|
table.print(mode);
|
||||||
render_approvals(&plan.approvals_required, 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
|
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
/// their `file`), routes pass through, triggers flatten into a tagged
|
||||||
/// array, secrets are names only.
|
/// array, secrets are names only.
|
||||||
@@ -277,4 +298,5 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
|||||||
}
|
}
|
||||||
table.print(mode);
|
table.print(mode);
|
||||||
render_approvals(&plan.approvals_required, mode);
|
render_approvals(&plan.approvals_required, mode);
|
||||||
|
render_warnings(&plan.warnings, mode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,9 @@ pub async fn deploy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(Some(_), Some(_)) | (None, None) => {
|
(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
|
// 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::config;
|
||||||
use crate::output::{OutputMode, Table};
|
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>> {
|
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||||
match (group, app) {
|
Ok(match crate::cmds::require_one_owner(app, group)? {
|
||||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
crate::cmds::OwnerRef::App(a) => VarOwnerArg::App(a),
|
||||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
crate::cmds::OwnerRef::Group(g) => VarOwnerArg::Group(g),
|
||||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
})
|
||||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ls(
|
pub async fn ls(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! is declarative via the `[suppress]` block (`triggers = [...]` handler script
|
//! is declarative via the `[suppress]` block (`triggers = [...]` handler script
|
||||||
//! names, `routes = [...]` paths).
|
//! names, `routes = [...]` paths).
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::Client;
|
||||||
use crate::config;
|
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<()> {
|
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let items = match (app, group) {
|
let items = match crate::cmds::require_one_owner(app, group)? {
|
||||||
(Some(a), None) => client.suppressions_list(a).await?,
|
crate::cmds::OwnerRef::App(a) => client.suppressions_list(a).await?,
|
||||||
(None, Some(g)) => client.group_suppressions_list(g).await?,
|
crate::cmds::OwnerRef::Group(g) => client.group_suppressions_list(g).await?,
|
||||||
_ => bail!("provide exactly one of --app or --group"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut table = Table::new(["kind", "reference"]);
|
let mut table = Table::new(["kind", "reference"]);
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ use crate::client::{Client, VarOwnerArg};
|
|||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::output::{OutputMode, Table};
|
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>> {
|
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||||
match (group, app) {
|
Ok(match crate::cmds::require_one_owner(app, group)? {
|
||||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
crate::cmds::OwnerRef::App(a) => VarOwnerArg::App(a),
|
||||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
crate::cmds::OwnerRef::Group(g) => VarOwnerArg::Group(g),
|
||||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
})
|
||||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ls(group: Option<&str>, app: Option<&str>, mode: OutputMode) -> Result<()> {
|
pub async fn ls(group: Option<&str>, app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
|
|||||||
@@ -249,7 +249,9 @@ struct ApplyArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
/// Skip the bound-plan staleness check (apply even if the app changed
|
/// 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)]
|
#[arg(long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
/// §7 multi-repo ownership: reassign a group node owned by another
|
/// §7 multi-repo ownership: reassign a group node owned by another
|
||||||
@@ -399,19 +401,24 @@ enum MembersCmd {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum FilesCmd {
|
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 {
|
Ls {
|
||||||
|
#[arg(long, conflicts_with = "group")]
|
||||||
|
app: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
collection: String,
|
collection: String,
|
||||||
#[arg(long, default_value_t = 100)]
|
#[arg(long, default_value_t = 100)]
|
||||||
limit: u32,
|
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 {
|
Get {
|
||||||
|
#[arg(long, conflicts_with = "group")]
|
||||||
|
app: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
collection: String,
|
collection: String,
|
||||||
#[arg(long = "id")]
|
#[arg(long = "id")]
|
||||||
@@ -419,7 +426,8 @@ enum FilesCmd {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Delete a file.
|
/// Delete a file. Per-app only — shared-collection blobs are not
|
||||||
|
/// operator-deletable (writes go through scripts).
|
||||||
Rm {
|
Rm {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
app: String,
|
||||||
@@ -1790,10 +1798,10 @@ async fn main() -> ExitCode {
|
|||||||
} => cmds::admins::rm(&id).await,
|
} => cmds::admins::rm(&id).await,
|
||||||
Cmd::Triggers {
|
Cmd::Triggers {
|
||||||
cmd: TriggersCmd::Ls { app, group },
|
cmd: TriggersCmd::Ls { app, group },
|
||||||
} => match (app, group) {
|
} => match cmds::require_one_owner(app.as_deref(), group.as_deref()) {
|
||||||
(_, Some(group)) => cmds::triggers::ls_group(&group, mode).await,
|
Ok(cmds::OwnerRef::App(a)) => cmds::triggers::ls(a, mode).await,
|
||||||
(Some(app), None) => cmds::triggers::ls(&app, mode).await,
|
Ok(cmds::OwnerRef::Group(g)) => cmds::triggers::ls_group(g, mode).await,
|
||||||
(None, None) => Err(anyhow::anyhow!("provide --app or --group")),
|
Err(e) => Err(e),
|
||||||
},
|
},
|
||||||
Cmd::Triggers {
|
Cmd::Triggers {
|
||||||
cmd: TriggersCmd::Rm { app, trigger_id },
|
cmd: TriggersCmd::Rm { app, trigger_id },
|
||||||
@@ -2101,19 +2109,30 @@ async fn main() -> ExitCode {
|
|||||||
cmd:
|
cmd:
|
||||||
FilesCmd::Ls {
|
FilesCmd::Ls {
|
||||||
app,
|
app,
|
||||||
|
group,
|
||||||
collection,
|
collection,
|
||||||
limit,
|
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::Files {
|
||||||
cmd:
|
cmd:
|
||||||
FilesCmd::Get {
|
FilesCmd::Get {
|
||||||
app,
|
app,
|
||||||
|
group,
|
||||||
collection,
|
collection,
|
||||||
file_id,
|
file_id,
|
||||||
out,
|
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::Files {
|
||||||
cmd:
|
cmd:
|
||||||
FilesCmd::Rm {
|
FilesCmd::Rm {
|
||||||
|
|||||||
Reference in New Issue
Block a user