From b33c87e5c49056b29fd09a8092d558e0381402b7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 6 Jul 2026 20:46:09 +0200 Subject: [PATCH] feat(cli): [project] manifest block, --takeover, groups ls owner column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes §7 ownership usable end to end from the CLI. - manifest: a `[project]` block (slug + optional name), independent of the [app]/[group] XOR; threaded onto every apply from the repo. --dir reads it from the tree ROOT's picloud.toml (a [project] elsewhere is ignored with a note). - client: apply_node/apply_tree carry project + takeover; the 409 branch now surfaces the server message verbatim (covers StateMoved AND OwnershipConflict, both self-contained). New groups_list_with_owner captures the owner slug. - pic apply --takeover flag; pic groups ls gains an `owner` column (the owning project's slug, or — when unclaimed). - server: /api/v1/admin/groups now returns each group flattened with its owner slug (via list_with_owner) — the shared Group deserialize ignores the extra field, so pic groups tree / the dashboard are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/groups_api.rs | 21 ++++++++++- crates/picloud-cli/src/client.rs | 53 +++++++++++++++++++------- crates/picloud-cli/src/cmds/apply.rs | 22 +++++++++-- crates/picloud-cli/src/cmds/groups.rs | 17 ++++++--- crates/picloud-cli/src/cmds/init.rs | 1 + crates/picloud-cli/src/cmds/plan.rs | 2 +- crates/picloud-cli/src/cmds/pull.rs | 1 + crates/picloud-cli/src/discover.rs | 28 +++++++++++--- crates/picloud-cli/src/main.rs | 7 ++++ crates/picloud-cli/src/manifest.rs | 54 +++++++++++++++++++++++++++ 10 files changed, 175 insertions(+), 31 deletions(-) diff --git a/crates/manager-core/src/groups_api.rs b/crates/manager-core/src/groups_api.rs index 92247b0..07dc79a 100644 --- a/crates/manager-core/src/groups_api.rs +++ b/crates/manager-core/src/groups_api.rs @@ -158,11 +158,28 @@ pub struct PatchMemberRequest { /// any authenticated admin sees the full tree; per-action authz still /// gates every mutation and all app access. Tighten in Phase 3 when groups /// carry inheritable config. +/// §7: a group plus its owning project's slug (`None` = unclaimed / UI-owned). +/// The group fields are flattened, so existing consumers (dashboard, `pic +/// groups tree`) keep working; `owner` is a new, optional field. +#[derive(Serialize)] +struct GroupListItem { + #[serde(flatten)] + group: Group, + owner: Option, +} + async fn list_groups( State(s): State, Extension(_principal): Extension, -) -> Result>, GroupsApiError> { - Ok(Json(s.groups.list().await?)) +) -> Result>, GroupsApiError> { + let items = s + .groups + .list_with_owner() + .await? + .into_iter() + .map(|(group, owner)| GroupListItem { group, owner }) + .collect(); + Ok(Json(items)) } async fn create_group( diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 791ff47..1265c8e 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -153,7 +153,8 @@ impl Client { // --- Groups (Phase 2) ------------------------------------------------- /// `GET /api/v1/admin/groups` — the full flat list (assemble the tree - /// client-side from `parent_id`). + /// client-side from `parent_id`). Ignores the §7 `owner` field the server + /// also returns (see [`Self::groups_list_with_owner`]). pub async fn groups_list(&self) -> Result> { let resp = self .request(Method::GET, "/api/v1/admin/groups") @@ -162,6 +163,17 @@ impl Client { decode(resp).await } + /// Same endpoint as [`Self::groups_list`], but captures the §7 owning + /// project's slug alongside each group — backs `pic groups ls`' owner + /// column. + pub async fn groups_list_with_owner(&self) -> Result> { + let resp = self + .request(Method::GET, "/api/v1/admin/groups") + .send() + .await?; + decode(resp).await + } + /// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children. pub async fn groups_get(&self, ident: &str) -> Result { let ident = seg(ident); @@ -1223,12 +1235,15 @@ impl Client { /// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an /// app OR group node in one transaction (Phase 5). + #[allow(clippy::too_many_arguments)] pub async fn apply_node( &self, kind: NodeKind, slug: &str, bundle: &serde_json::Value, prune: bool, + project: Option<&crate::manifest::ManifestProject>, + takeover: bool, expected_token: Option<&str>, ) -> Result { let slug = seg(slug); @@ -1236,6 +1251,8 @@ impl Client { "bundle": bundle, "prune": prune, "expected_token": expected_token, + "project": project, + "takeover": takeover, }); let resp = self .request( @@ -1245,18 +1262,13 @@ impl Client { .json(&body) .send() .await?; - // The apply endpoint returns 409 only for a stale bound plan - // (`StateMoved`): the app changed since `pic plan` recorded its - // token. Surface an actionable next step instead of a bare - // `HTTP 409`. + // The apply endpoint returns 409 for a stale bound plan (`StateMoved`) + // OR a §7 ownership conflict. Both server messages are self-contained + // and actionable, so surface the message verbatim. 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 live state changed since your last `pic plan`. Re-run \ - `pic plan` to review the new diff, then `pic apply` — or \ - `pic apply --force` to apply without re-reviewing." - )); + return Err(anyhow!("{msg}")); } decode(resp).await } @@ -1277,25 +1289,27 @@ impl Client { &self, bundle: &serde_json::Value, prune: bool, + project: Option<&crate::manifest::ManifestProject>, + takeover: bool, expected_token: Option<&str>, ) -> Result { let body = serde_json::json!({ "bundle": bundle, "prune": prune, "expected_token": expected_token, + "project": project, + "takeover": takeover, }); let resp = self .request(Method::POST, "/api/v1/admin/tree/apply") .json(&body) .send() .await?; + // 409 = stale bound plan OR a §7 ownership conflict; surface verbatim. 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`." - )); + return Err(anyhow!("{msg}")); } decode(resp).await } @@ -1652,6 +1666,17 @@ pub struct GroupDetailDto { pub apps: Vec, } +/// One row of `pic groups ls`: a group plus its §7 owning project's slug +/// (`None` = unclaimed / UI-owned). The group fields flatten in, so `.group.*` +/// reaches them. +#[derive(Debug, Deserialize)] +pub struct GroupListItem { + #[serde(flatten)] + pub group: Group, + #[serde(default)] + pub owner: Option, +} + #[derive(Debug, Serialize)] pub struct CreateRouteBody<'a> { pub host_kind: HostKind, diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index aa27aa8..ea03268 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -20,6 +20,7 @@ pub async fn run( prune: bool, yes: bool, force: bool, + takeover: bool, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; @@ -52,7 +53,15 @@ pub async fn run( }; let report = client - .apply_node(kind, &slug, &bundle, prune, expected_token.as_deref()) + .apply_node( + kind, + &slug, + &bundle, + prune, + manifest.project.as_ref(), + takeover, + expected_token.as_deref(), + ) .await?; crate::linkstate::clear_plan(base_dir, &slug); @@ -120,12 +129,13 @@ pub async fn run_tree( prune: bool, yes: bool, force: bool, + takeover: 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)?; + let (bundle, node_count, project) = crate::discover::build_tree(dir, env)?; if prune && !yes { confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?; @@ -141,7 +151,13 @@ pub async fn run_tree( }; let report = client - .apply_tree(&bundle, prune, expected_token.as_deref()) + .apply_tree( + &bundle, + prune, + project.as_ref(), + takeover, + expected_token.as_deref(), + ) .await?; crate::linkstate::clear_plan(dir, token_key); diff --git a/crates/picloud-cli/src/cmds/groups.rs b/crates/picloud-cli/src/cmds/groups.rs index a498a3a..b3f42e2 100644 --- a/crates/picloud-cli/src/cmds/groups.rs +++ b/crates/picloud-cli/src/cmds/groups.rs @@ -16,19 +16,24 @@ use crate::output::{KvBlock, OutputMode, Table}; pub async fn ls(mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let groups = client.groups_list().await?; - let mut table = Table::new(["slug", "name", "parent", "created_at"]); - let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect(); + let groups = client.groups_list_with_owner().await?; + let mut table = Table::new(["slug", "name", "parent", "owner", "created_at"]); + let by_id: BTreeMap<_, _> = groups + .iter() + .map(|g| (g.group.id, g.group.slug.clone())) + .collect(); for g in &groups { let parent = g + .group .parent_id .and_then(|p| by_id.get(&p).cloned()) .unwrap_or_else(|| "-".into()); table.row([ - g.slug.clone(), - g.name.clone(), + g.group.slug.clone(), + g.group.name.clone(), parent, - g.created_at.to_rfc3339(), + g.owner.clone().unwrap_or_else(|| "—".into()), + g.group.created_at.to_rfc3339(), ]); } table.print(mode); diff --git a/crates/picloud-cli/src/cmds/init.rs b/crates/picloud-cli/src/cmds/init.rs index 06aef33..588afe5 100644 --- a/crates/picloud-cli/src/cmds/init.rs +++ b/crates/picloud-cli/src/cmds/init.rs @@ -116,6 +116,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest { extension_points: Vec::new(), }), group: None, + project: None, scripts: vec![ManifestScript { name: "hello".into(), file: "scripts/hello.rhai".into(), diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index 55da10f..0d0481b 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -49,7 +49,7 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R 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 (bundle, _count, _project) = 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) { diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 8bbd4ff..935d5d9 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -242,6 +242,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> extension_points, }), group: None, + project: None, scripts: manifest_scripts, routes, triggers: manifest_triggers, diff --git a/crates/picloud-cli/src/discover.rs b/crates/picloud-cli/src/discover.rs index 499fdbf..f9aeaf2 100644 --- a/crates/picloud-cli/src/discover.rs +++ b/crates/picloud-cli/src/discover.rs @@ -11,7 +11,7 @@ use anyhow::{bail, Context, Result}; use serde_json::{json, Value}; use crate::cmds::plan::build_bundle; -use crate::manifest::{Manifest, MANIFEST_FILE}; +use crate::manifest::{Manifest, ManifestProject, MANIFEST_FILE}; /// Find every `picloud.toml` under `root` (recursively), skipping the /// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env @@ -46,9 +46,14 @@ fn walk(dir: &Path, out: &mut Vec) -> Result<()> { } /// 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)> { +/// manifest under `root`. Returns the JSON, the node count, and the owning +/// `[project]` (§7) declared in the tree ROOT's `picloud.toml` (if any) — one +/// project owns the whole tree; a `[project]` in any other node is ignored with +/// a note. Rejects a tree that names the same (kind, slug) twice. +pub fn build_tree( + root: &Path, + env: Option<&str>, +) -> Result<(Value, usize, Option)> { let paths = find_manifests(root)?; if paths.is_empty() { bail!( @@ -56,11 +61,24 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> { root.display() ); } + let root_manifest = root.join(MANIFEST_FILE); + let mut project: Option = None; 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()))?; + if let Some(p) = &manifest.project { + if path.as_path() == root_manifest { + project = Some(p.clone()); + } else { + eprintln!( + "note: [project] in {} is ignored — declare it in the tree root {}", + path.display(), + root_manifest.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" }; @@ -71,5 +89,5 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> { nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle })); } let count = nodes.len(); - Ok((json!({ "nodes": nodes }), count)) + Ok((json!({ "nodes": nodes }), count, project)) } diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 52d1ab4..ae7dea5 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -246,6 +246,11 @@ struct ApplyArgs { /// since the last `pic plan`). #[arg(long)] force: bool, + /// §7 multi-repo ownership: reassign a group node owned by another + /// `[project]` to this manifest's project. Requires group-admin on the + /// node; without it an apply to an owned node is refused (409). + #[arg(long)] + takeover: bool, /// Merge the `picloud..toml` overlay (per-env slug + secrets) on /// top of the base manifest before applying. #[arg(long)] @@ -1421,6 +1426,7 @@ async fn main() -> ExitCode { args.prune, args.yes, args.force, + args.takeover, args.env.as_deref(), mode, ) @@ -1433,6 +1439,7 @@ async fn main() -> ExitCode { args.prune, args.yes, args.force, + args.takeover, mode, ) .await diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index e82ff8c..9bfa8a0 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -37,6 +37,12 @@ pub struct Manifest { pub app: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub group: Option, + /// `[project]` (§7 multi-repo ownership) — the repo-root that OWNS the nodes + /// it applies. Independent of the app/group node kind; declared in the + /// repo's root manifest and threaded onto every apply from it. The first + /// apply with a new slug claims each group node it touches. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub scripts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -263,6 +269,17 @@ pub struct ManifestGroup { pub collections: Vec, } +/// A `[project]` block (§7 multi-repo ownership): the identity of the repo-root +/// managing these nodes. The `slug` is committed (stable across clones); the +/// server assigns the UUID and the first apply registers it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestProject { + pub slug: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + /// The store kind of a shared collection (§11.6). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -596,6 +613,7 @@ mod tests { extension_points: vec!["theme".into()], }), group: None, + project: None, scripts: vec![ ManifestScript { name: "create-post".into(), @@ -759,6 +777,7 @@ mod tests { extension_points: vec![], }), group: None, + project: None, scripts: vec![], routes: vec![], triggers: ManifestTriggers::default(), @@ -921,6 +940,41 @@ mod tests { .expect_err("both [app] and [group]"); } + #[test] + fn project_block_parses_independently_of_node_kind() { + // §7: [project] is orthogonal to the app/group XOR — either node kind + // may declare the owning project. + let a = Manifest::parse( + "[project]\nslug = \"platform\"\nname = \"Platform\"\n\n\ + [app]\nslug = \"web\"\nname = \"Web\"\n", + ) + .expect("[app] + [project] parses"); + assert_eq!(a.project.as_ref().unwrap().slug, "platform"); + assert_eq!( + a.project.as_ref().unwrap().name.as_deref(), + Some("Platform") + ); + + let g = Manifest::parse( + "[project]\nslug = \"platform\"\n\n[group]\nslug = \"acme\"\nname = \"ACME\"\n", + ) + .expect("[group] + [project] parses"); + assert_eq!(g.project.as_ref().unwrap().slug, "platform"); + assert!( + g.project.as_ref().unwrap().name.is_none(), + "name is optional" + ); + + // Absent [project] → None (backward-compatible). + assert!(Manifest::parse("[app]\nslug=\"w\"\nname=\"W\"\n") + .unwrap() + .project + .is_none()); + // Unknown key inside [project] is rejected (deny_unknown_fields). + Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n") + .expect_err("unknown [project] key rejected"); + } + #[test] fn overlay_vars_override_base_per_key() { let mut base = sample();