feat(cli): nested-manifest discovery + pic plan/apply --dir tree mode (Phase 5 C4)

Phase 5 C4. `pic plan --dir <root>` / `pic apply --dir <root>` discover every
`picloud.toml` under a directory (groups + apps), assemble the wire tree
bundle, and drive the C3 `/tree/{plan,apply}` endpoints — a whole project
subtree reconciled and reviewed as one unit.

  * New `discover` module: recursive walk (skips `.picloud`/`.git`/`scripts`/
    `target`), one node per manifest, app/group inferred from the manifest;
    rejects a duplicate (kind, slug). On-disk nesting is organizational — the
    server resolves ancestry from its own group tree.
  * `client`: `plan_tree`/`apply_tree` + `TreePlanDto`/`NodePlanDto`. `pic plan
    --dir` renders a per-node diff table and records ONE tree bound-token under
    a `<tree>` linkstate marker; `pic apply --dir` replays it (force/`--yes`/
    prune apply tree-wide). `--dir` conflicts with `--file`.

Live-validated: a nested tree (root group dir + nested app dir, app route bound
to the group's inherited script) → `pic plan --dir .` shows both nodes' changes
→ `pic apply --dir .` applies all in one tx → re-plan all-noop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 22:25:29 +02:00
parent 8a559ea178
commit 1662d7806a
5 changed files with 292 additions and 13 deletions

View File

@@ -1227,6 +1227,45 @@ impl Client {
}
decode(resp).await
}
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
let resp = self
.request(Method::POST, "/api/v1/admin/tree/plan")
.json(bundle)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
/// transaction (Phase 5).
pub async fn apply_tree(
&self,
bundle: &serde_json::Value,
prune: bool,
expected_token: Option<&str>,
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
.json(&body)
.send()
.await?;
if resp.status() == reqwest::StatusCode::CONFLICT {
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!(
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
));
}
decode(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
@@ -1295,6 +1334,32 @@ pub struct ChangeDto {
pub detail: Option<String>,
}
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
/// bound-plan token for the whole subtree.
#[derive(Debug, Deserialize)]
pub struct TreePlanDto {
#[serde(default)]
pub nodes: Vec<NodePlanDto>,
#[serde(default)]
pub state_token: String,
}
#[derive(Debug, Deserialize)]
pub struct NodePlanDto {
pub kind: String,
pub slug: String,
#[serde(default)]
pub scripts: Vec<ChangeDto>,
#[serde(default)]
pub routes: Vec<ChangeDto>,
#[serde(default)]
pub triggers: Vec<ChangeDto>,
#[serde(default)]
pub secrets: Vec<ChangeDto>,
#[serde(default)]
pub vars: Vec<ChangeDto>,
}
/// Response of `POST .../apply`: counts of what changed.
#[derive(Debug, Default, Deserialize)]
pub struct ApplyReportDto {

View File

@@ -92,6 +92,73 @@ pub async fn run(
Ok(())
}
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
/// `picloud.toml` under `root` — in ONE atomic server transaction.
pub async fn run_tree(
dir: &Path,
prune: bool,
yes: bool,
force: bool,
env: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, node_count) = crate::discover::build_tree(dir, env)?;
if prune && !yes {
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
}
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
let expected_token = if force {
None
} else {
crate::linkstate::read_plan(dir)
.filter(|l| l.app == token_key)
.map(|l| l.state_token)
};
let report = client
.apply_tree(&bundle, prune, expected_token.as_deref())
.await?;
crate::linkstate::clear_plan(dir, token_key);
let mut block = KvBlock::new();
block
.field("nodes", node_count.to_string())
.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`

View File

@@ -9,11 +9,15 @@ use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::client::{ChangeDto, Client, NodeKind, PlanDto};
use crate::client::{ChangeDto, Client, NodeKind, PlanDto, TreePlanDto};
use crate::config;
use crate::manifest::Manifest;
use crate::output::{OutputMode, Table};
/// Linkstate key for a whole-tree bound plan (Phase 5) — a tree has no single
/// slug, so its token is stored under this reserved marker.
pub const TREE_TOKEN_KEY: &str = "<tree>";
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
@@ -40,6 +44,48 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
Ok(())
}
/// `pic plan --dir <root>` (Phase 5): diff a whole project tree — every
/// `picloud.toml` under `root` — against the live group/app subtree.
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, _count) = crate::discover::build_tree(dir, env)?;
let plan = client.plan_tree(&bundle).await?;
if !plan.state_token.is_empty() {
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
}
}
render_tree(&plan, mode);
Ok(())
}
fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug);
let groups: [(&str, &Vec<ChangeDto>); 5] = [
("script", &n.scripts),
("route", &n.routes),
("trigger", &n.triggers),
("secret", &n.secrets),
("var", &n.vars),
];
for (rk, changes) in groups {
for c in changes {
table.row([
node.clone(),
rk.to_string(),
c.op.clone(),
c.key.clone(),
c.detail.clone().unwrap_or_default(),
]);
}
}
}
table.print(mode);
}
/// 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.

View File

@@ -0,0 +1,75 @@
//! Phase 5: discover a project *tree* — every `picloud.toml` under a root
//! directory — and assemble the wire tree bundle the server's `/tree/{plan,
//! apply}` endpoints consume. Each manifest becomes one node (app or group);
//! the server resolves ancestry from its own group tree, so the on-disk
//! nesting is organizational, not authoritative here.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use crate::cmds::plan::build_bundle;
use crate::manifest::{Manifest, MANIFEST_FILE};
/// Find every `picloud.toml` under `root` (recursively), skipping the
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
/// overlays (`picloud.<env>.toml`) are not matched — only the base filename.
pub fn find_manifests(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
walk(root, &mut out)?;
out.sort();
Ok(out)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let ft = entry.file_type()?;
if ft.is_dir() {
if matches!(
name.to_str(),
Some(".picloud" | ".git" | "scripts" | "target")
) {
continue;
}
walk(&entry.path(), out)?;
} else if name == MANIFEST_FILE {
out.push(entry.path());
}
}
Ok(())
}
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
/// that names the same (kind, slug) twice.
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
let paths = find_manifests(root)?;
if paths.is_empty() {
bail!(
"no {MANIFEST_FILE} found under {} — nothing to apply",
root.display()
);
}
let mut nodes = Vec::with_capacity(paths.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() { "group" } else { "app" };
let slug = manifest.slug().to_string();
if !seen.insert((kind.to_string(), slug.clone())) {
bail!("project tree declares {kind} `{slug}` more than once");
}
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
}
let count = nodes.len();
Ok((json!({ "nodes": nodes }), count))
}

View File

@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
mod client;
mod cmds;
mod config;
mod discover;
mod linkstate;
mod manifest;
mod output;
@@ -202,6 +203,11 @@ struct ApplyArgs {
/// Path to the manifest.
#[arg(long, default_value = "picloud.toml")]
file: PathBuf,
/// Phase 5: apply a whole project TREE — every `picloud.toml` under this
/// directory (groups + apps) reconciled in one atomic transaction.
/// Mutually exclusive with `--file`.
#[arg(long, conflicts_with = "file")]
dir: Option<PathBuf>,
/// Delete live scripts/routes/triggers absent from the manifest.
/// Secrets are never pruned.
#[arg(long)]
@@ -225,6 +231,10 @@ struct PlanArgs {
/// Path to the manifest.
#[arg(long, default_value = "picloud.toml")]
file: PathBuf,
/// Phase 5: plan a whole project TREE — every `picloud.toml` under this
/// directory (groups + apps). Mutually exclusive with `--file`.
#[arg(long, conflicts_with = "file")]
dir: Option<PathBuf>,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before diffing.
#[arg(long)]
@@ -1329,18 +1339,34 @@ async fn main() -> ExitCode {
}
Cmd::Logout => cmds::logout::run().await,
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => {
cmds::apply::run(
&args.file,
args.env.as_deref(),
args.prune,
args.yes,
args.force,
mode,
)
.await
}
Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
Cmd::Apply(args) => match &args.dir {
Some(dir) => {
cmds::apply::run_tree(
dir,
args.prune,
args.yes,
args.force,
args.env.as_deref(),
mode,
)
.await
}
None => {
cmds::apply::run(
&args.file,
args.env.as_deref(),
args.prune,
args.yes,
args.force,
mode,
)
.await
}
},
Cmd::Plan(args) => match &args.dir {
Some(dir) => cmds::plan::run_tree(dir, args.env.as_deref(), mode).await,
None => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
},
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
Cmd::Config(args) => {
cmds::config::run(