feat(cli): [group] manifest + pic plan/apply for a group node (Phase 5 C2)

Phase 5 C2. A `picloud.toml` can now declare a `[group]` node (instead of
`[app]`): the group's own scripts + `[vars]` (+ secret names). `pic plan` /
`pic apply` reconcile it via the C1 group endpoints — the same plan/apply/
bound-token flow as an app, routed by node kind.

  * `Manifest` is now app-XOR-group: `app`/`group` are both optional with an
    exactly-one check in `parse`, plus a group-node guard that rejects
    `[[routes]]`/`[[triggers]]` (those bind to an app). Accessors `slug()` /
    `is_group()` replace the hardcoded `manifest.app.slug`.
  * `client`: `plan_node`/`apply_node` take a `NodeKind { App | Group }` that
    selects the `apps` vs `groups` API path; the old `plan`/`apply` wrappers
    are gone (callers pass the kind).
  * `pic plan`/`apply` detect the node kind from the manifest and label output
    `group`/`app`; `pic config --effective` cleanly rejects a group manifest
    (effective config is an app's inherited view).

Live-validated: a `[group]` manifest with a script + var → `pic plan` (script
+ var creates) → `pic apply` (group-owned) → idempotent re-plan noop →
`pic scripts ls --group` shows it. Manifest unit test for group parse +
app-only-block rejection; init/pull/plan/apply/overlay journeys all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 21:57:58 +02:00
parent 65049a6262
commit da03fda1d6
7 changed files with 183 additions and 51 deletions

View File

@@ -1168,33 +1168,47 @@ impl Client {
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
/// bundle against the app's live state. Read-only.
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
let app = seg(app);
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/plan` — diff a bundle
/// against an app OR group node (Phase 5).
pub async fn plan_node(
&self,
kind: NodeKind,
slug: &str,
bundle: &serde_json::Value,
) -> Result<PlanDto> {
let slug = seg(slug);
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
.request(
Method::POST,
&format!("/api/v1/admin/{}/{slug}/plan", kind.path()),
)
.json(bundle)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
/// app to the bundle in one transaction.
pub async fn apply(
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
/// app OR group node in one transaction (Phase 5).
pub async fn apply_node(
&self,
app: &str,
kind: NodeKind,
slug: &str,
bundle: &serde_json::Value,
prune: bool,
expected_token: Option<&str>,
) -> Result<ApplyReportDto> {
let app = seg(app);
let slug = seg(slug);
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
});
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
.request(
Method::POST,
&format!("/api/v1/admin/{}/{slug}/apply", kind.path()),
)
.json(&body)
.send()
.await?;
@@ -1206,7 +1220,7 @@ impl Client {
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!(
"{msg}\nThe app changed since your last `pic plan`. Re-run \
"{msg}\nThe live state changed since your last `pic plan`. Re-run \
`pic plan` to review the new diff, then `pic apply` — or \
`pic apply --force` to apply without re-reviewing."
));
@@ -1235,6 +1249,23 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
decode(resp).await
}
/// Which kind of node a plan/apply targets (Phase 5) — selects the API path
/// prefix (`apps` vs `groups`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
App,
Group,
}
impl NodeKind {
fn path(self) -> &'static str {
match self {
Self::App => "apps",
Self::Group => "groups",
}
}
}
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
/// Response of `POST .../plan`: per-resource diffs grouped by kind.