feat(modules): no-provider plan check + read-only extension-points ls (§5.5 C4)
- apply: `check_extension_points_provided` (app nodes) — every EP visible on
the app's chain (its own + inherited) must have a provider: a same-apply
module, or one resolvable up-chain (override or default body). Else a clean
`pic plan` error instead of a runtime failure. Single-node only (the tree
path leans on the runtime backstop, like the dangling-import check).
- read-only surface: `extension_point_report` + `ExtensionPointInfo`;
`GET /{apps|groups}/{id}/extension-points` (AppRead / GroupScriptsRead);
`pic extension-points ls --app|--group` showing declared-vs-inherited + the
resolved provider (`app override` / `inherited default` / `unset`).
- `pic pull` round-trips an app's OWN declared EPs (filtered `declared_here`),
so pull→plan stays idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1305,6 +1305,35 @@ impl NodeKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the §5.5 extension-point report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionPointInfoDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub declared_here: bool,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
|
||||
pub async fn extension_points_list(
|
||||
&self,
|
||||
kind: NodeKind,
|
||||
ident: &str,
|
||||
) -> Result<Vec<ExtensionPointInfoDto>> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/{}/{ident}/extension-points", kind.path()),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
|
||||
48
crates/picloud-cli/src/cmds/extension_points.rs
Normal file
48
crates/picloud-cli/src/cmds/extension_points.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! `pic extension-points ls --app|--group` — read-only view of a node's
|
||||
//! §5.5 extension points. For an app, each EP visible on its chain is shown
|
||||
//! with its resolved provider (`app override` / `inherited default` / `unset`);
|
||||
//! for a group, its own declared names. Authoring is declarative only (the
|
||||
//! manifest `extension_points = [...]`).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::{Client, NodeKind};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let (kind, ident) = match (app, group) {
|
||||
(Some(a), None) => (NodeKind::App, a),
|
||||
(None, Some(g)) => (NodeKind::Group, g),
|
||||
_ => bail!("`extension-points ls` requires exactly one of --app or --group"),
|
||||
};
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = client.extension_points_list(kind, ident).await?;
|
||||
|
||||
match kind {
|
||||
NodeKind::App => {
|
||||
let mut table = Table::new(["name", "source", "provider"]);
|
||||
for ep in &items {
|
||||
table.row([
|
||||
ep.name.clone(),
|
||||
if ep.declared_here {
|
||||
"declared".into()
|
||||
} else {
|
||||
"inherited".into()
|
||||
},
|
||||
ep.provider.clone().unwrap_or_else(|| "unset".into()),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let mut table = Table::new(["name"]);
|
||||
for ep in &items {
|
||||
table.row([ep.name.clone()]);
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod extension_points;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
|
||||
@@ -49,6 +49,15 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
// The app's OWN declared extension points (§5.5) — inherited ones belong to
|
||||
// the ancestor group's manifest, so filter to `declared_here`.
|
||||
let extension_points: Vec<String> = client
|
||||
.extension_points_list(crate::client::NodeKind::App, app_ident)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|ep| ep.declared_here)
|
||||
.map(|ep| ep.name)
|
||||
.collect();
|
||||
// App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
|
||||
// group vars and tombstones are excluded (pull exports own rows only, §4.6);
|
||||
// a value TOML can't represent (e.g. JSON null) is skipped with a warning.
|
||||
@@ -224,8 +233,7 @@ 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,
|
||||
// Wired to the read endpoint in C4 so pull→plan round-trips EPs.
|
||||
extension_points: Vec::new(),
|
||||
extension_points,
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -153,6 +153,15 @@ enum Cmd {
|
||||
cmd: VarsCmd,
|
||||
},
|
||||
|
||||
/// Extension points (§5.5) — read-only view of the module names a node
|
||||
/// marks as overridable. Authored declaratively via the manifest
|
||||
/// `extension_points = [...]`; this lists them + (for an app) each one's
|
||||
/// resolved provider.
|
||||
ExtensionPoints {
|
||||
#[command(subcommand)]
|
||||
cmd: ExtensionPointsCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
@@ -537,6 +546,18 @@ enum DomainsCmd {
|
||||
Rm { app: String, domain_id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ExtensionPointsCmd {
|
||||
/// List a node's extension points. `--app` shows every EP visible on the
|
||||
/// app's chain with its resolved provider; `--group` shows the group's own.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
||||
@@ -1961,6 +1982,9 @@ async fn main() -> ExitCode {
|
||||
env,
|
||||
},
|
||||
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
|
||||
Cmd::ExtensionPoints {
|
||||
cmd: ExtensionPointsCmd::Ls { app, group },
|
||||
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
|
||||
Reference in New Issue
Block a user