diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index bfc0e41..8355ae8 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -183,6 +183,12 @@ async fn group_extension_points_handler( /// `[project]`, so the plan can preview the ownership outcome + attach ceiling. #[derive(Deserialize)] pub struct PlanRequest { + // Flattened so the bundle fields sit at the JSON top level: an older `pic` + // (pre-§7) that POSTs a bare bundle still deserializes (`project` defaults + // to `None`), and a newer client sends the same bundle fields plus a + // top-level `project` key. Keeps the plan wire compatible in both skew + // directions, matching the additive compatibility of the apply request. + #[serde(flatten)] pub bundle: Bundle, #[serde(default)] pub project: Option, @@ -327,6 +333,8 @@ struct TreeApplyRequest { #[derive(Deserialize)] struct TreePlanRequest { + // Flattened for the same bare-bundle wire compatibility as `PlanRequest`. + #[serde(flatten)] bundle: TreeBundle, #[serde(default)] project: Option, @@ -577,3 +585,41 @@ impl IntoResponse for ApplyError { (status, Json(body)).into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + + // §7 wire compat: the plan endpoints `#[serde(flatten)]` the bundle, so a + // pre-§7 client that POSTs a BARE bundle (no `{bundle: ...}` wrapper, no + // `project`) must still deserialize — `project` defaults to `None`. + #[test] + fn plan_request_accepts_a_bare_bundle() { + let bare = serde_json::json!({ + "scripts": [], + "secrets": ["TOKEN"], + }); + let req: PlanRequest = serde_json::from_value(bare).expect("bare bundle deserializes"); + assert!(req.project.is_none()); + assert_eq!(req.bundle.secrets, vec!["TOKEN".to_string()]); + } + + // The newer wire shape — the same bundle fields at the top level plus a + // `project` key (what the current CLI sends) — also deserializes. + #[test] + fn plan_request_accepts_a_flattened_project() { + let with_proj = serde_json::json!({ + "scripts": [], + "project": { "slug": "acme" }, + }); + let req: PlanRequest = serde_json::from_value(with_proj).expect("flattened project"); + assert_eq!(req.project.expect("project").slug, "acme"); + } + + #[test] + fn tree_plan_request_accepts_a_bare_bundle() { + let bare = serde_json::json!({ "nodes": [] }); + let req: TreePlanRequest = serde_json::from_value(bare).expect("bare tree bundle"); + assert!(req.project.is_none()); + } +} diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 079159b..cc53960 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -39,6 +39,25 @@ fn seg(s: &str) -> String { utf8_percent_encode(s, PATH_SEGMENT).to_string() } +/// Build a `plan` request body: the bundle fields at the JSON top level plus an +/// optional `project` key (§7). The plan endpoints `#[serde(flatten)]` the +/// bundle, so a bare bundle (no `[project]`) is the pre-§7 wire shape — this +/// keeps `pic plan` compatible across a CLI/server version skew in either +/// direction (an old server ignores the extra `project`; a new server accepts +/// the bare bundle). +fn plan_body( + bundle: &serde_json::Value, + project: Option<&crate::manifest::ManifestProject>, +) -> Result { + let mut body = bundle.clone(); + if let Some(p) = project { + if let Some(obj) = body.as_object_mut() { + obj.insert("project".to_string(), serde_json::to_value(p)?); + } + } + Ok(body) +} + pub struct Client { http: reqwest::Client, url: String, @@ -1233,7 +1252,7 @@ impl Client { project: Option<&crate::manifest::ManifestProject>, ) -> Result { let slug = seg(slug); - let body = serde_json::json!({ "bundle": bundle, "project": project }); + let body = plan_body(bundle, project)?; let resp = self .request( Method::POST, @@ -1291,7 +1310,7 @@ impl Client { bundle: &serde_json::Value, project: Option<&crate::manifest::ManifestProject>, ) -> Result { - let body = serde_json::json!({ "bundle": bundle, "project": project }); + let body = plan_body(bundle, project)?; let resp = self .request(Method::POST, "/api/v1/admin/tree/plan") .json(&body)