use std::path::Path;
use chrono::Utc;
use super::audit::{self, AuditReport, AuditVerdict};
use super::manifest::AddonManifest;
use crate::core::context_package::content::{AddonContent, PackageContent};
use crate::core::context_package::manifest::{
CompatibilitySpec, PackageIntegrity, PackageKind, PackageManifest, PackageProvenance,
PackageStats,
};
use crate::core::context_package::{keys, signing, verify};
#[derive(Debug)]
pub struct AddonPackPlan {
pub namespace: String,
pub slug: String,
pub version: String,
pub bundle_json: String,
pub audit: AuditReport,
pub artifact_platforms: Vec<String>,
pub has_bootstrap: bool,
}
pub fn validate_namespace(ns: &str) -> Result<(), String> {
let ok_len = (2..=39).contains(&ns.len());
let ok_chars = ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
let ok_edges = !ns.starts_with('-') && !ns.ends_with('-') && !ns.contains("--");
if ok_len && ok_chars && ok_edges {
Ok(())
} else {
Err(format!(
"invalid namespace `{ns}` — lowercase letters, digits and single dashes, \
2–39 characters (e.g. `acme` or `das-tholo`)"
))
}
}
pub fn build_addon_pack(manifest_path: &Path, namespace: &str) -> Result<AddonPackPlan, String> {
validate_namespace(namespace)?;
let toml_text = std::fs::read_to_string(manifest_path)
.map_err(|e| format!("read {}: {e}", manifest_path.display()))?;
let addon = AddonManifest::from_toml(&toml_text)?;
addon.validate()?;
if !addon.is_installable() {
return Err(
"the addon has no runnable [mcp] endpoint — nothing to publish (fill in \
`[mcp] command` or a remote `url`)"
.into(),
);
}
if addon.addon.description.trim().is_empty() {
return Err("addon.description is required for a published listing".into());
}
let report = audit::audit(&addon);
if report.verdict == AuditVerdict::Fail {
let blocking: Vec<String> = report
.findings
.iter()
.map(|f| format!("{} — {}", f.code, f.message))
.collect();
return Err(format!(
"audit verdict: FAIL — the hosted registry refuses this listing, so publish \
stops here:\n {}",
blocking.join("\n ")
));
}
let slug = addon.addon.name.clone();
let version = addon.addon.version.clone();
let pack_name = format!("@{namespace}/{slug}");
let content = PackageContent {
addon: Some(AddonContent {
manifest_toml: toml_text,
}),
..PackageContent::default()
};
let content_json = serde_json::to_string(&content).map_err(|e| e.to_string())?;
let content_hash = sha256_hex(content_json.as_bytes());
let sha256 = sha256_hex(format!("{pack_name}:{version}:{content_hash}").as_bytes());
let mut manifest = PackageManifest {
schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
conformance_level: None,
kind: PackageKind::Addon,
name: pack_name,
version: version.clone(),
description: addon.addon.description.clone(),
author: (!addon.addon.author.trim().is_empty()).then(|| addon.addon.author.clone()),
scope: Some(format!("@{namespace}")),
created_at: Utc::now(),
updated_at: None,
layers: Vec::new(),
dependencies: addon.dependencies.clone(),
tags: addon
.addon
.categories
.iter()
.chain(addon.addon.keywords.iter())
.cloned()
.collect(),
visibility: None,
integrity: PackageIntegrity {
sha256,
content_hash,
byte_size: content_json.len() as u64,
},
provenance: PackageProvenance {
tool: "lean-ctx".into(),
tool_version: env!("CARGO_PKG_VERSION").into(),
project_hash: None,
source_session_id: None,
},
compatibility: CompatibilitySpec::default(),
stats: PackageStats::default(),
signature: None,
graph_summary: None,
marketplace: None,
};
manifest.validate().map_err(|errs| errs.join("; "))?;
verify::validate_kind_coherence(&manifest, &content).map_err(|errs| errs.join("; "))?;
let (signing_key, created) = keys::load_or_create()?;
if created {
tracing::info!("ctxpkg: created a new ed25519 signing key for this machine");
}
signing::sign_package(&mut manifest, &content, &signing_key);
#[derive(serde::Serialize)]
struct Bundle<'a> {
manifest: &'a PackageManifest,
content: &'a PackageContent,
}
let bundle_json = serde_json::to_string_pretty(&Bundle {
manifest: &manifest,
content: &content,
})
.map_err(|e| e.to_string())?;
let self_check = verify::verify_package_text(&bundle_json);
if !self_check.valid() {
return Err(format!(
"internal error — the built pack fails verification: {}",
self_check.errors.join("; ")
));
}
Ok(AddonPackPlan {
namespace: namespace.to_string(),
slug,
version,
bundle_json,
audit: report,
artifact_platforms: addon.artifacts.keys().cloned().collect(),
has_bootstrap: !addon.install.is_absent(),
})
}
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data);
crate::core::agent_identity::hex_encode(&h.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD_TOML: &str = r#"
[addon]
name = "lean-md"
version = "1.2.0"
description = "Markdown skills runtime for lean agents"
author = "dasTholo"
categories = ["skills"]
keywords = ["markdown"]
[mcp]
transport = "stdio"
command = "lean-md"
args = ["serve"]
sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
[capabilities]
network = "none"
filesystem = "read_only"
exec = "none"
[artifacts.aarch64-apple-darwin]
filename = "lean-md-aarch64-apple-darwin"
url = "https://github.com/dastholo/lean-md/releases/download/v1.2.0/lean-md-aarch64-apple-darwin"
sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
"#;
fn write_manifest(dir: &tempfile::TempDir, text: &str) -> std::path::PathBuf {
let p = dir.path().join("lean-ctx-addon.toml");
std::fs::write(&p, text).expect("write manifest");
p
}
#[test]
fn namespace_rules() {
assert!(validate_namespace("acme").is_ok());
assert!(validate_namespace("das-tholo").is_ok());
assert!(validate_namespace("a").is_err());
assert!(validate_namespace("Bad").is_err());
assert!(validate_namespace("-x-").is_err());
assert!(validate_namespace("a--b").is_err());
}
#[test]
fn builds_a_signed_verifying_addon_pack() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_manifest(&dir, GOOD_TOML);
let plan = build_addon_pack(&path, "das-tholo").expect("plan");
assert_eq!(plan.slug, "lean-md");
assert_eq!(plan.version, "1.2.0");
assert_eq!(plan.artifact_platforms, vec!["aarch64-apple-darwin"]);
assert!(!plan.has_bootstrap);
assert_eq!(plan.audit.verdict, AuditVerdict::Pass);
let report = verify::verify_package_text(&plan.bundle_json);
assert!(report.valid(), "errors: {:?}", report.errors);
let (ns, name, version) =
crate::core::context_package::remote::preflight_bundle(plan.bundle_json.as_bytes())
.expect("preflight");
assert_eq!((ns.as_str(), name.as_str()), ("das-tholo", "lean-md"));
assert_eq!(version, "1.2.0");
}
#[test]
fn embedded_toml_is_verbatim() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_manifest(&dir, GOOD_TOML);
let plan = build_addon_pack(&path, "das-tholo").expect("plan");
let doc: serde_json::Value = serde_json::from_str(&plan.bundle_json).expect("json");
assert_eq!(
doc["content"]["addon"]["manifest_toml"].as_str(),
Some(GOOD_TOML)
);
assert_eq!(doc["manifest"]["kind"].as_str(), Some("addon"));
}
#[test]
fn refuses_shell_exec_wiring() {
let dir = tempfile::tempdir().expect("tempdir");
let bad = GOOD_TOML
.replace("command = \"lean-md\"", "command = \"bash\"")
.replace("args = [\"serve\"]", "args = [\"-c\", \"echo hi\"]");
let path = write_manifest(&dir, &bad);
let err = build_addon_pack(&path, "acme").expect_err("must fail");
assert!(err.contains("FAIL"), "got: {err}");
}
#[test]
fn refuses_missing_description() {
let dir = tempfile::tempdir().expect("tempdir");
let bad = GOOD_TOML.replace(
"description = \"Markdown skills runtime for lean agents\"",
"description = \"\"",
);
let path = write_manifest(&dir, &bad);
let err = build_addon_pack(&path, "acme").expect_err("must fail");
assert!(err.contains("description"), "got: {err}");
}
#[test]
fn build_addon_pack_forwards_declared_dependencies() {
let dir = std::env::temp_dir().join(format!("lc-addon-deps-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("tmp dir");
let path = dir.join("lean-ctx-addon.toml");
std::fs::write(
&path,
r#"
[addon]
name = "demo"
version = "0.2.0"
description = "a demo addon"
[mcp]
command = "demo-bin"
[[dependencies]]
name = "@dasTholo/lean-md-skills"
version_req = "^0.2"
"#,
)
.expect("write manifest");
let plan = build_addon_pack(&path, "dastholo").expect("builds");
std::fs::remove_dir_all(&dir).ok();
let bundle: serde_json::Value =
serde_json::from_str(&plan.bundle_json).expect("bundle json");
let deps = &bundle["manifest"]["dependencies"];
assert_eq!(deps[0]["name"], "@dasTholo/lean-md-skills");
assert_eq!(deps[0]["version_req"], "^0.2");
}
}