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:
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
75
crates/picloud-cli/src/discover.rs
Normal file
75
crates/picloud-cli/src/discover.rs
Normal 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))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user