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:
MechaCat02
2026-07-06 20:46:09 +02:00
parent 47072c481d
commit b33c87e5c4
10 changed files with 175 additions and 31 deletions

View File

@@ -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<String>,
}
async fn list_groups(
State(s): State<GroupsState>,
Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<Group>>, GroupsApiError> {
Ok(Json(s.groups.list().await?))
) -> Result<Json<Vec<GroupListItem>>, 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(

View File

@@ -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<Vec<Group>> {
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<Vec<GroupListItem>> {
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<GroupDetailDto> {
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<ApplyReportDto> {
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<ApplyReportDto> {
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<App>,
}
/// 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<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateRouteBody<'a> {
pub host_kind: HostKind,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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<PathBuf>) -> 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<ManifestProject>)> {
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<ManifestProject> = 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))
}

View File

@@ -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.<env>.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

View File

@@ -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();