feat(cli): [project] manifest block, --takeover, groups ls owner column
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) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,12 @@ pub struct Manifest {
|
||||
pub app: Option<ManifestApp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<ManifestGroup>,
|
||||
/// `[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<ManifestProject>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -263,6 +269,17 @@ pub struct ManifestGroup {
|
||||
pub collections: Vec<CollectionDecl>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
Reference in New Issue
Block a user