test(cli): extension-point journeys + node-key manifest fix + docs (§5.5 C5)
- Move the manifest `extension_points` from a top-level key to an `[app]`/ `[group]` node key (`ManifestApp`/`ManifestGroup` + a `Manifest::extension_points()` accessor). A bare top-level array placed after the node header was silently re-nested by TOML into that table and dropped; as a node key it parses unambiguously wherever it sits. build_bundle/pull/init updated. - Journeys (live server + Postgres): a group default body resolves for an inheriting app; the app provides its own module and OVERRIDES the EP (the inverse of the Phase 4b sealed import); `extension-points ls` shows the provider; a body-less EP with no provider fails `pic plan`. - Re-bless the schema snapshot (new `extension_points` table). - Docs: §5.5 marked complete (extension points ✅) + CLAUDE.md current-focus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
extension_points: Vec::new(),
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![ManifestScript {
|
||||
@@ -139,7 +140,6 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
"extension_points": manifest.extension_points,
|
||||
"extension_points": manifest.extension_points(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
extension_points,
|
||||
}),
|
||||
group: None,
|
||||
scripts: manifest_scripts,
|
||||
@@ -233,7 +234,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
extension_points,
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -49,12 +49,6 @@ pub struct Manifest {
|
||||
/// The overlay merges per-env, last-write-wins by key.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
/// `extension_points = ["theme", …]` (§5.5) — module names this node marks
|
||||
/// as provided/overridable by descendants. Name-only, like `[secrets]`; the
|
||||
/// optional default body is a co-located `[[scripts]]` module of the same
|
||||
/// name. Allowed on app and group nodes.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -105,6 +99,17 @@ impl Manifest {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// This node's declared extension-point names (§5.5), from whichever of
|
||||
/// `[app]` / `[group]` is present.
|
||||
#[must_use]
|
||||
pub fn extension_points(&self) -> &[String] {
|
||||
match (&self.app, &self.group) {
|
||||
(Some(a), _) => &a.extension_points,
|
||||
(_, Some(g)) => &g.extension_points,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let body =
|
||||
@@ -209,6 +214,12 @@ pub struct ManifestApp {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// `extension_points = ["theme", …]` (§5.5) — module names this node marks
|
||||
/// as provided/overridable by descendants. Name-only, like `[secrets]`; the
|
||||
/// optional default body is a co-located `[[scripts]]` module of the same
|
||||
/// name. A key of the `[app]` table so TOML can't mis-nest it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
}
|
||||
|
||||
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
|
||||
@@ -221,6 +232,9 @@ pub struct ManifestGroup {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -432,6 +446,7 @@ mod tests {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
extension_points: vec!["theme".into()],
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![
|
||||
@@ -494,7 +509,6 @@ mod tests {
|
||||
("region".to_string(), toml::Value::String("eu".into())),
|
||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||
]),
|
||||
extension_points: vec!["theme".into()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,6 +605,7 @@ mod tests {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
extension_points: vec![],
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![],
|
||||
@@ -598,7 +613,6 @@ mod tests {
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::new(),
|
||||
extension_points: vec![],
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
|
||||
Reference in New Issue
Block a user