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

@@ -9,7 +9,7 @@ use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::client::{ChangeDto, Client, PlanDto};
use crate::client::{ChangeDto, Client, NodeKind, PlanDto};
use crate::config;
use crate::manifest::Manifest;
use crate::output::{OutputMode, Table};
@@ -21,15 +21,18 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
let manifest = Manifest::load_with_env(manifest_path, env)?;
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() {
NodeKind::Group
} else {
NodeKind::App
};
let plan = client.plan(&manifest.app.slug, &bundle).await?;
let plan = client.plan_node(kind, manifest.slug(), &bundle).await?;
// Record the bound-plan token so a subsequent `pic apply` can detect the
// app changing underneath the reviewed plan (best-effort — a read-only
// node changing underneath the reviewed plan (best-effort — a read-only
// plan still succeeds if the project dir isn't writable).
if !plan.state_token.is_empty() {
if let Err(e) =
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
{
if let Err(e) = crate::linkstate::write_plan(base_dir, manifest.slug(), &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
}
}