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:
@@ -8,7 +8,7 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, NodeKind};
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
@@ -28,36 +28,38 @@ pub async fn run(
|
||||
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 slug = manifest.slug().to_string();
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&manifest.app.slug)?;
|
||||
confirm_prune(&slug)?;
|
||||
}
|
||||
|
||||
// Bound-plan check: replay the token from the last `pic plan` (for this
|
||||
// app) so the server refuses if the app changed since it was reviewed.
|
||||
// node) so the server refuses if it changed since it was reviewed.
|
||||
// `--force` skips it; no recorded plan means no check (apply still works
|
||||
// standalone). The token is single-use — cleared after a successful apply.
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(base_dir)
|
||||
.filter(|l| l.app == manifest.app.slug)
|
||||
.filter(|l| l.app == slug)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply(
|
||||
&manifest.app.slug,
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
)
|
||||
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
|
||||
crate::linkstate::clear_plan(base_dir, &slug);
|
||||
|
||||
let label = if manifest.is_group() { "group" } else { "app" };
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field(label, slug.clone())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
|
||||
@@ -45,8 +45,14 @@ pub async fn run(
|
||||
// masked report targets the app those commands would act on — not the
|
||||
// base slug — when an overlay re-points slug/secrets.
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
if manifest.is_group() {
|
||||
bail!(
|
||||
"`pic config --effective` resolves an APP's inherited config; \
|
||||
a [group] manifest has no single effective view"
|
||||
);
|
||||
}
|
||||
|
||||
let eff = client.config_effective(&manifest.app.slug).await?;
|
||||
let eff = client.config_effective(manifest.slug()).await?;
|
||||
|
||||
// --- vars: the resolved view, with winning owner + provenance. ---
|
||||
let mut vars_table = Table::new(["key", "value", "owner", "scope"]);
|
||||
|
||||
@@ -109,11 +109,12 @@ pub fn run(
|
||||
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
|
||||
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,11 +211,12 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
@@ -231,7 +232,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field("app", manifest.slug().to_string())
|
||||
.field("scripts", manifest.scripts.len().to_string())
|
||||
.field("routes", manifest.routes.len().to_string())
|
||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||
|
||||
Reference in New Issue
Block a user