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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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