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>
263 lines
8.2 KiB
Rust
263 lines
8.2 KiB
Rust
//! `pic groups` — manage the org-tree groups (Phase 2).
|
|
//!
|
|
//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated
|
|
//! server-side: create/reparent/delete need group-admin (reparent at both
|
|
//! source and destination parent); the slug is frozen at creation.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use anyhow::Result;
|
|
use picloud_shared::{AppRole, Group};
|
|
|
|
use crate::client::{Client, CreateGroupBody};
|
|
use crate::config;
|
|
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_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.group.slug.clone(),
|
|
g.group.name.clone(),
|
|
parent,
|
|
g.owner.clone().unwrap_or_else(|| "—".into()),
|
|
g.group.created_at.to_rfc3339(),
|
|
]);
|
|
}
|
|
table.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// `pic groups tree` — render the hierarchy as an indented tree (text
|
|
/// mode); falls back to the flat list for `--output json`.
|
|
pub async fn tree(mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let groups = client.groups_list().await?;
|
|
if matches!(mode, OutputMode::Json) {
|
|
// Machine consumers get the flat list; the shape carries parent_id.
|
|
println!("{}", serde_json::to_string_pretty(&groups)?);
|
|
return Ok(());
|
|
}
|
|
// children-by-parent, then DFS from the roots.
|
|
let mut children: BTreeMap<Option<_>, Vec<&Group>> = BTreeMap::new();
|
|
for g in &groups {
|
|
children.entry(g.parent_id).or_default().push(g);
|
|
}
|
|
for kids in children.values_mut() {
|
|
kids.sort_by(|a, b| a.name.cmp(&b.name));
|
|
}
|
|
print_subtree(&children, None, 0);
|
|
Ok(())
|
|
}
|
|
|
|
fn print_subtree(
|
|
children: &BTreeMap<Option<picloud_shared::GroupId>, Vec<&Group>>,
|
|
parent: Option<picloud_shared::GroupId>,
|
|
depth: usize,
|
|
) {
|
|
let Some(kids) = children.get(&parent) else {
|
|
return;
|
|
};
|
|
for g in kids {
|
|
println!("{}{} ({})", " ".repeat(depth), g.name, g.slug);
|
|
print_subtree(children, Some(g.id), depth + 1);
|
|
}
|
|
}
|
|
|
|
pub async fn create(
|
|
slug: &str,
|
|
name: Option<&str>,
|
|
description: Option<&str>,
|
|
parent: Option<&str>,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let body = CreateGroupBody {
|
|
slug,
|
|
name: name.unwrap_or(slug),
|
|
description,
|
|
parent,
|
|
};
|
|
let group = client.groups_create(&body).await?;
|
|
print_group(&group, mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let detail = client.groups_get(ident).await?;
|
|
let path = detail
|
|
.path
|
|
.iter()
|
|
.map(|g| g.slug.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" / ");
|
|
let mut block = KvBlock::new();
|
|
block
|
|
.field("id", detail.group.id.to_string())
|
|
.field("slug", detail.group.slug.clone())
|
|
.field("name", detail.group.name.clone())
|
|
.field("path", if path.is_empty() { "-".into() } else { path })
|
|
.field(
|
|
"subgroups",
|
|
detail
|
|
.subgroups
|
|
.iter()
|
|
.map(|g| g.slug.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
)
|
|
.field(
|
|
"apps",
|
|
detail
|
|
.apps
|
|
.iter()
|
|
.map(|a| a.slug.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
);
|
|
block.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn rename(
|
|
ident: &str,
|
|
name: Option<&str>,
|
|
description: Option<&str>,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let group = client.groups_rename(ident, name, description).await?;
|
|
print_group(&group, mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let group = client.groups_reparent(ident, to).await?;
|
|
print_group(&group, mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// `pic groups rm <slug>`. The server enforces delete=RESTRICT (409 on a
|
|
/// non-empty group); `--recursive` expands the delete into ordered,
|
|
/// leaf-first child deletions (groups + apps) the operator opted into.
|
|
pub async fn rm(ident: &str, recursive: bool) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
if !recursive {
|
|
client.groups_delete(ident).await?;
|
|
println!("Deleted group {ident}");
|
|
return Ok(());
|
|
}
|
|
// Recursive: delete the subtree leaf-first so each DB delete sees an
|
|
// empty node (the FK stays RESTRICT — we never cascade implicitly).
|
|
let detail = client.groups_get(ident).await?;
|
|
if let Some(app) = detail.apps.first() {
|
|
anyhow::bail!(
|
|
"group {ident} contains app {:?}; move or delete apps before a recursive group delete \
|
|
(apps are never auto-deleted)",
|
|
app.slug
|
|
);
|
|
}
|
|
for sub in &detail.subgroups {
|
|
Box::pin(rm(&sub.slug, true)).await?;
|
|
}
|
|
client.groups_delete(ident).await?;
|
|
println!("Deleted group {ident}");
|
|
Ok(())
|
|
}
|
|
|
|
// --- members ---------------------------------------------------------------
|
|
|
|
pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let members = client.group_members_list(group).await?;
|
|
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
|
for m in members {
|
|
table.row([
|
|
m.user_id.to_string(),
|
|
m.username,
|
|
m.role.as_str().to_string(),
|
|
format!("{:?}", m.instance_role).to_lowercase(),
|
|
m.is_active.to_string(),
|
|
]);
|
|
}
|
|
table.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let m = client
|
|
.group_members_grant(group, user_id, parse_role(role)?)
|
|
.await?;
|
|
print_member(&m, mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let m = client
|
|
.group_members_set_role(group, user_id, parse_role(role)?)
|
|
.await?;
|
|
print_member(&m, mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn members_rm(group: &str, user_id: &str) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
client.group_members_remove(group, user_id).await?;
|
|
println!("Removed {user_id} from {group}");
|
|
Ok(())
|
|
}
|
|
|
|
fn print_group(g: &Group, mode: OutputMode) {
|
|
let mut block = KvBlock::new();
|
|
block
|
|
.field("id", g.id.to_string())
|
|
.field("slug", g.slug.clone())
|
|
.field("name", g.name.clone())
|
|
.field(
|
|
"parent_id",
|
|
g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()),
|
|
)
|
|
.field("created_at", g.created_at.to_rfc3339());
|
|
block.print(mode);
|
|
}
|
|
|
|
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
|
let mut block = KvBlock::new();
|
|
block
|
|
.field("user_id", m.user_id.to_string())
|
|
.field("username", m.username.clone())
|
|
.field("role", m.role.as_str().to_string());
|
|
block.print(mode);
|
|
}
|
|
|
|
fn parse_role(role: &str) -> Result<AppRole> {
|
|
AppRole::from_db_str(role)
|
|
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
|
|
}
|