diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index f20afcd..8d8aee1 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -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 { + 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 { + 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, } +/// 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, + #[serde(default)] + pub state_token: String, +} + +#[derive(Debug, Deserialize)] +pub struct NodePlanDto { + pub kind: String, + pub slug: String, + #[serde(default)] + pub scripts: Vec, + #[serde(default)] + pub routes: Vec, + #[serde(default)] + pub triggers: Vec, + #[serde(default)] + pub secrets: Vec, + #[serde(default)] + pub vars: Vec, +} + /// Response of `POST .../apply`: counts of what changed. #[derive(Debug, Default, Deserialize)] pub struct ApplyReportDto { diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index c1940d1..b830170 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -92,6 +92,73 @@ pub async fn run( Ok(()) } +/// `pic apply --dir ` (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` diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index 01d8806..2c51e68 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -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 = ""; + 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 ` (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); 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. diff --git a/crates/picloud-cli/src/discover.rs b/crates/picloud-cli/src/discover.rs new file mode 100644 index 0000000..499fdbf --- /dev/null +++ b/crates/picloud-cli/src/discover.rs @@ -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..toml`) are not matched — only the base filename. +pub fn find_manifests(root: &Path) -> Result> { + let mut out = Vec::new(); + walk(root, &mut out)?; + out.sort(); + Ok(out) +} + +fn walk(dir: &Path, out: &mut Vec) -> 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)) +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 88567f2..5023883 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -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, /// 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, /// Merge the `picloud..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(