Files
PiCloud/crates/picloud-cli/src/cmds/apply.rs
MechaCat02 da03fda1d6 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>
2026-06-25 21:57:58 +02:00

121 lines
3.9 KiB
Rust

//! `pic apply [--file picloud.toml]` — reconcile the live app to the
//! manifest's desired state in one server-side transaction. Creates and
//! updates are applied; `--prune` additionally deletes live scripts/routes/
//! triggers absent from the manifest (secrets are never pruned).
use std::io::{IsTerminal, Write};
use std::path::Path;
use anyhow::{Context, Result};
use crate::client::{Client, NodeKind};
use crate::cmds::plan::build_bundle;
use crate::config;
use crate::manifest::Manifest;
use crate::output::{KvBlock, OutputMode};
pub async fn run(
manifest_path: &Path,
env: Option<&str>,
prune: bool,
yes: bool,
force: bool,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
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(&slug)?;
}
// Bound-plan check: replay the token from the last `pic plan` (for this
// 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 == slug)
.map(|l| l.state_token)
};
let report = client
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
.await?;
crate::linkstate::clear_plan(base_dir, &slug);
let label = if manifest.is_group() { "group" } else { "app" };
let mut block = KvBlock::new();
block
.field(label, slug.clone())
.field(
"scripts",
format!(
"+{} ~{} -{}",
report.scripts_created, report.scripts_updated, report.scripts_deleted
),
)
.field(
"routes",
format!(
"+{} ~{} -{}",
report.routes_created, report.routes_updated, report.routes_deleted
),
)
.field(
"triggers",
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
)
.field(
"vars",
format!(
"+{} ~{} -{}",
report.vars_created, report.vars_updated, report.vars_deleted
),
);
for w in &report.warnings {
block.field("warning", w.clone());
}
block.print(mode);
Ok(())
}
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
/// rather than silently deleting (review the deletions first with `pic plan`).
fn confirm_prune(slug: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"refusing to `apply --prune` non-interactively without `--yes`: prune \
deletes resources absent from the manifest and cannot be undone. \
Review with `pic plan`, then re-run with `--yes`."
);
}
eprint!(
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
absent from the manifest. This cannot be undone. 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(())
}