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
|
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
|
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||||
@@ -1295,6 +1334,32 @@ pub struct ChangeDto {
|
|||||||
pub detail: Option<String>,
|
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.
|
/// Response of `POST .../apply`: counts of what changed.
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
pub struct ApplyReportDto {
|
pub struct ApplyReportDto {
|
||||||
|
|||||||
@@ -92,6 +92,73 @@ pub async fn run(
|
|||||||
Ok(())
|
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 —
|
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
||||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--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::Serialize;
|
||||||
use serde_json::{json, Map, Value};
|
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::config;
|
||||||
use crate::manifest::Manifest;
|
use crate::manifest::Manifest;
|
||||||
use crate::output::{OutputMode, Table};
|
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<()> {
|
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
@@ -40,6 +44,48 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
|
|||||||
Ok(())
|
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
|
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
/// their `file`), routes pass through, triggers flatten into a tagged
|
||||||
/// array, secrets are names only.
|
/// 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 client;
|
||||||
mod cmds;
|
mod cmds;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod discover;
|
||||||
mod linkstate;
|
mod linkstate;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
mod output;
|
mod output;
|
||||||
@@ -202,6 +203,11 @@ struct ApplyArgs {
|
|||||||
/// Path to the manifest.
|
/// Path to the manifest.
|
||||||
#[arg(long, default_value = "picloud.toml")]
|
#[arg(long, default_value = "picloud.toml")]
|
||||||
file: PathBuf,
|
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.
|
/// Delete live scripts/routes/triggers absent from the manifest.
|
||||||
/// Secrets are never pruned.
|
/// Secrets are never pruned.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -225,6 +231,10 @@ struct PlanArgs {
|
|||||||
/// Path to the manifest.
|
/// Path to the manifest.
|
||||||
#[arg(long, default_value = "picloud.toml")]
|
#[arg(long, default_value = "picloud.toml")]
|
||||||
file: PathBuf,
|
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
|
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||||
/// top of the base manifest before diffing.
|
/// top of the base manifest before diffing.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -1329,18 +1339,34 @@ async fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
Cmd::Logout => cmds::logout::run().await,
|
Cmd::Logout => cmds::logout::run().await,
|
||||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||||
Cmd::Apply(args) => {
|
Cmd::Apply(args) => match &args.dir {
|
||||||
cmds::apply::run(
|
Some(dir) => {
|
||||||
&args.file,
|
cmds::apply::run_tree(
|
||||||
args.env.as_deref(),
|
dir,
|
||||||
args.prune,
|
args.prune,
|
||||||
args.yes,
|
args.yes,
|
||||||
args.force,
|
args.force,
|
||||||
mode,
|
args.env.as_deref(),
|
||||||
)
|
mode,
|
||||||
.await
|
)
|
||||||
}
|
.await
|
||||||
Cmd::Plan(args) => cmds::plan::run(&args.file, 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::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
|
||||||
Cmd::Config(args) => {
|
Cmd::Config(args) => {
|
||||||
cmds::config::run(
|
cmds::config::run(
|
||||||
|
|||||||
Reference in New Issue
Block a user