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
|
/// any authenticated admin sees the full tree; per-action authz still
|
||||||
/// gates every mutation and all app access. Tighten in Phase 3 when groups
|
/// gates every mutation and all app access. Tighten in Phase 3 when groups
|
||||||
/// carry inheritable config.
|
/// 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(
|
async fn list_groups(
|
||||||
State(s): State<GroupsState>,
|
State(s): State<GroupsState>,
|
||||||
Extension(_principal): Extension<Principal>,
|
Extension(_principal): Extension<Principal>,
|
||||||
) -> Result<Json<Vec<Group>>, GroupsApiError> {
|
) -> Result<Json<Vec<GroupListItem>>, GroupsApiError> {
|
||||||
Ok(Json(s.groups.list().await?))
|
let items = s
|
||||||
|
.groups
|
||||||
|
.list_with_owner()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(group, owner)| GroupListItem { group, owner })
|
||||||
|
.collect();
|
||||||
|
Ok(Json(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_group(
|
async fn create_group(
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ impl Client {
|
|||||||
// --- Groups (Phase 2) -------------------------------------------------
|
// --- Groups (Phase 2) -------------------------------------------------
|
||||||
|
|
||||||
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
|
/// `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>> {
|
pub async fn groups_list(&self) -> Result<Vec<Group>> {
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(Method::GET, "/api/v1/admin/groups")
|
.request(Method::GET, "/api/v1/admin/groups")
|
||||||
@@ -162,6 +163,17 @@ impl Client {
|
|||||||
decode(resp).await
|
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.
|
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
|
||||||
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
|
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
|
||||||
let ident = seg(ident);
|
let ident = seg(ident);
|
||||||
@@ -1223,12 +1235,15 @@ impl Client {
|
|||||||
|
|
||||||
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
|
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
|
||||||
/// app OR group node in one transaction (Phase 5).
|
/// app OR group node in one transaction (Phase 5).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn apply_node(
|
pub async fn apply_node(
|
||||||
&self,
|
&self,
|
||||||
kind: NodeKind,
|
kind: NodeKind,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
bundle: &serde_json::Value,
|
bundle: &serde_json::Value,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
|
project: Option<&crate::manifest::ManifestProject>,
|
||||||
|
takeover: bool,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
) -> Result<ApplyReportDto> {
|
) -> Result<ApplyReportDto> {
|
||||||
let slug = seg(slug);
|
let slug = seg(slug);
|
||||||
@@ -1236,6 +1251,8 @@ impl Client {
|
|||||||
"bundle": bundle,
|
"bundle": bundle,
|
||||||
"prune": prune,
|
"prune": prune,
|
||||||
"expected_token": expected_token,
|
"expected_token": expected_token,
|
||||||
|
"project": project,
|
||||||
|
"takeover": takeover,
|
||||||
});
|
});
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(
|
.request(
|
||||||
@@ -1245,18 +1262,13 @@ impl Client {
|
|||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
// The apply endpoint returns 409 only for a stale bound plan
|
// The apply endpoint returns 409 for a stale bound plan (`StateMoved`)
|
||||||
// (`StateMoved`): the app changed since `pic plan` recorded its
|
// OR a §7 ownership conflict. Both server messages are self-contained
|
||||||
// token. Surface an actionable next step instead of a bare
|
// and actionable, so surface the message verbatim.
|
||||||
// `HTTP 409`.
|
|
||||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
let msg = parse_error_body(&body).unwrap_or(body);
|
let msg = parse_error_body(&body).unwrap_or(body);
|
||||||
return Err(anyhow!(
|
return Err(anyhow!("{msg}"));
|
||||||
"{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."
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
@@ -1277,25 +1289,27 @@ impl Client {
|
|||||||
&self,
|
&self,
|
||||||
bundle: &serde_json::Value,
|
bundle: &serde_json::Value,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
|
project: Option<&crate::manifest::ManifestProject>,
|
||||||
|
takeover: bool,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
) -> Result<ApplyReportDto> {
|
) -> Result<ApplyReportDto> {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"bundle": bundle,
|
"bundle": bundle,
|
||||||
"prune": prune,
|
"prune": prune,
|
||||||
"expected_token": expected_token,
|
"expected_token": expected_token,
|
||||||
|
"project": project,
|
||||||
|
"takeover": takeover,
|
||||||
});
|
});
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
// 409 = stale bound plan OR a §7 ownership conflict; surface verbatim.
|
||||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
let msg = parse_error_body(&body).unwrap_or(body);
|
let msg = parse_error_body(&body).unwrap_or(body);
|
||||||
return Err(anyhow!(
|
return Err(anyhow!("{msg}"));
|
||||||
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
|
|
||||||
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
@@ -1652,6 +1666,17 @@ pub struct GroupDetailDto {
|
|||||||
pub apps: Vec<App>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CreateRouteBody<'a> {
|
pub struct CreateRouteBody<'a> {
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub async fn run(
|
|||||||
prune: bool,
|
prune: bool,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
|
takeover: bool,
|
||||||
mode: OutputMode,
|
mode: OutputMode,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
@@ -52,7 +53,15 @@ pub async fn run(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let report = client
|
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?;
|
.await?;
|
||||||
crate::linkstate::clear_plan(base_dir, &slug);
|
crate::linkstate::clear_plan(base_dir, &slug);
|
||||||
|
|
||||||
@@ -120,12 +129,13 @@ pub async fn run_tree(
|
|||||||
prune: bool,
|
prune: bool,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
|
takeover: bool,
|
||||||
env: Option<&str>,
|
env: Option<&str>,
|
||||||
mode: OutputMode,
|
mode: OutputMode,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
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 {
|
if prune && !yes {
|
||||||
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
||||||
@@ -141,7 +151,13 @@ pub async fn run_tree(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let report = client
|
let report = client
|
||||||
.apply_tree(&bundle, prune, expected_token.as_deref())
|
.apply_tree(
|
||||||
|
&bundle,
|
||||||
|
prune,
|
||||||
|
project.as_ref(),
|
||||||
|
takeover,
|
||||||
|
expected_token.as_deref(),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
crate::linkstate::clear_plan(dir, token_key);
|
crate::linkstate::clear_plan(dir, token_key);
|
||||||
|
|
||||||
|
|||||||
@@ -16,19 +16,24 @@ use crate::output::{KvBlock, OutputMode, Table};
|
|||||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let groups = client.groups_list().await?;
|
let groups = client.groups_list_with_owner().await?;
|
||||||
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
|
let mut table = Table::new(["slug", "name", "parent", "owner", "created_at"]);
|
||||||
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
|
let by_id: BTreeMap<_, _> = groups
|
||||||
|
.iter()
|
||||||
|
.map(|g| (g.group.id, g.group.slug.clone()))
|
||||||
|
.collect();
|
||||||
for g in &groups {
|
for g in &groups {
|
||||||
let parent = g
|
let parent = g
|
||||||
|
.group
|
||||||
.parent_id
|
.parent_id
|
||||||
.and_then(|p| by_id.get(&p).cloned())
|
.and_then(|p| by_id.get(&p).cloned())
|
||||||
.unwrap_or_else(|| "-".into());
|
.unwrap_or_else(|| "-".into());
|
||||||
table.row([
|
table.row([
|
||||||
g.slug.clone(),
|
g.group.slug.clone(),
|
||||||
g.name.clone(),
|
g.group.name.clone(),
|
||||||
parent,
|
parent,
|
||||||
g.created_at.to_rfc3339(),
|
g.owner.clone().unwrap_or_else(|| "—".into()),
|
||||||
|
g.group.created_at.to_rfc3339(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
table.print(mode);
|
table.print(mode);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
|||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: vec![ManifestScript {
|
scripts: vec![ManifestScript {
|
||||||
name: "hello".into(),
|
name: "hello".into(),
|
||||||
file: "scripts/hello.rhai".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<()> {
|
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
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?;
|
let plan = client.plan_tree(&bundle).await?;
|
||||||
if !plan.state_token.is_empty() {
|
if !plan.state_token.is_empty() {
|
||||||
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
|
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,
|
extension_points,
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: manifest_scripts,
|
scripts: manifest_scripts,
|
||||||
routes,
|
routes,
|
||||||
triggers: manifest_triggers,
|
triggers: manifest_triggers,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use anyhow::{bail, Context, Result};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::cmds::plan::build_bundle;
|
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
|
/// Find every `picloud.toml` under `root` (recursively), skipping the
|
||||||
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
|
/// `.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
|
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
|
||||||
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
|
/// manifest under `root`. Returns the JSON, the node count, and the owning
|
||||||
/// that names the same (kind, slug) twice.
|
/// `[project]` (§7) declared in the tree ROOT's `picloud.toml` (if any) — one
|
||||||
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
/// 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)?;
|
let paths = find_manifests(root)?;
|
||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -56,11 +61,24 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
|||||||
root.display()
|
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 nodes = Vec::with_capacity(paths.len());
|
||||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||||
for path in &paths {
|
for path in &paths {
|
||||||
let manifest = Manifest::load_with_env(path, env)
|
let manifest = Manifest::load_with_env(path, env)
|
||||||
.with_context(|| format!("loading {}", path.display()))?;
|
.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 base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
let bundle = build_bundle(&manifest, base_dir)?;
|
||||||
let kind = if manifest.is_group() { "group" } else { "app" };
|
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 }));
|
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
|
||||||
}
|
}
|
||||||
let count = nodes.len();
|
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`).
|
/// since the last `pic plan`).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
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
|
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||||
/// top of the base manifest before applying.
|
/// top of the base manifest before applying.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -1421,6 +1426,7 @@ async fn main() -> ExitCode {
|
|||||||
args.prune,
|
args.prune,
|
||||||
args.yes,
|
args.yes,
|
||||||
args.force,
|
args.force,
|
||||||
|
args.takeover,
|
||||||
args.env.as_deref(),
|
args.env.as_deref(),
|
||||||
mode,
|
mode,
|
||||||
)
|
)
|
||||||
@@ -1433,6 +1439,7 @@ async fn main() -> ExitCode {
|
|||||||
args.prune,
|
args.prune,
|
||||||
args.yes,
|
args.yes,
|
||||||
args.force,
|
args.force,
|
||||||
|
args.takeover,
|
||||||
mode,
|
mode,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ pub struct Manifest {
|
|||||||
pub app: Option<ManifestApp>,
|
pub app: Option<ManifestApp>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub group: Option<ManifestGroup>,
|
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")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub scripts: Vec<ManifestScript>,
|
pub scripts: Vec<ManifestScript>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -263,6 +269,17 @@ pub struct ManifestGroup {
|
|||||||
pub collections: Vec<CollectionDecl>,
|
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).
|
/// The store kind of a shared collection (§11.6).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -596,6 +613,7 @@ mod tests {
|
|||||||
extension_points: vec!["theme".into()],
|
extension_points: vec!["theme".into()],
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: vec![
|
scripts: vec![
|
||||||
ManifestScript {
|
ManifestScript {
|
||||||
name: "create-post".into(),
|
name: "create-post".into(),
|
||||||
@@ -759,6 +777,7 @@ mod tests {
|
|||||||
extension_points: vec![],
|
extension_points: vec![],
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: vec![],
|
scripts: vec![],
|
||||||
routes: vec![],
|
routes: vec![],
|
||||||
triggers: ManifestTriggers::default(),
|
triggers: ManifestTriggers::default(),
|
||||||
@@ -921,6 +940,41 @@ mod tests {
|
|||||||
.expect_err("both [app] and [group]");
|
.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]
|
#[test]
|
||||||
fn overlay_vars_override_base_per_key() {
|
fn overlay_vars_override_base_per_key() {
|
||||||
let mut base = sample();
|
let mut base = sample();
|
||||||
|
|||||||
Reference in New Issue
Block a user