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

@@ -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);
}