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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user