systemprompt_cli/commands/core/services/
bundle.rs1use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use clap::Args;
10use serde::Serialize;
11use systemprompt_loader::bundle::{pack, verify};
12use systemprompt_manifest::services::bundle::{
13 BUNDLE_SIGNATURE_ALG, BundleSignature, BundleSourceInfo, SignedBundleManifest,
14};
15use systemprompt_security::manifest_signing::{canonical_manifest_bytes, sign_with_seed};
16
17use super::signing::{BundleSigningKey, load_signing_key};
18use crate::shared::CommandOutput;
19
20#[derive(Debug, Clone, Args)]
21pub struct BundleArgs {
22 #[arg(long, help = "Services tree to pack")]
23 pub root: PathBuf,
24
25 #[arg(long, help = "Archive to write (.tar.gz)")]
26 pub out: PathBuf,
27
28 #[arg(long, help = "Bundle version stamped into bundle.json")]
29 pub version: String,
30
31 #[arg(long, help = "Signing key: a file holding the base64 seed, or env:VAR")]
32 pub sign_key: Option<String>,
33
34 #[arg(long, help = "Repository the tree was authored in")]
35 pub source_repo: Option<String>,
36
37 #[arg(long, help = "Commit the tree was packed from")]
38 pub source_commit: Option<String>,
39
40 #[arg(long, help = "CI run that produced the archive")]
41 pub workflow_run: Option<String>,
42
43 #[arg(
44 long,
45 help = "Refuse to pack any directory outside the marketplace set"
46 )]
47 pub marketplace_only: bool,
48}
49
50#[derive(Debug, Serialize)]
51pub struct BundleOutcome {
52 pub archive: String,
53 pub version: String,
54 pub requires_core: String,
55 pub content_hash: String,
56 pub files: usize,
57 pub total_size: u64,
58 pub dirs: String,
59 pub signed_by: Option<String>,
60 pub public_key: Option<String>,
61}
62
63pub fn execute(args: &BundleArgs) -> Result<CommandOutput> {
64 let key = args.sign_key.as_deref().map(load_signing_key).transpose()?;
65 let outcome = pack_bundle(args, key.as_ref())?;
66 Ok(CommandOutput::card_value("Services Bundle", &outcome))
67}
68
69pub fn pack_bundle(args: &BundleArgs, key: Option<&BundleSigningKey>) -> Result<BundleOutcome> {
70 let manifest = pack::build_manifest(
71 &args.root,
72 &args.version,
73 &requires_core(env!("CARGO_PKG_VERSION"))?,
74 BundleSourceInfo {
75 repo: args.source_repo.clone(),
76 commit: args.source_commit.clone(),
77 workflow_run: args.workflow_run.clone(),
78 },
79 )
80 .with_context(|| format!("Failed to read services tree {}", args.root.display()))?;
81
82 if args.marketplace_only {
83 verify::require_marketplace_only(&manifest)
84 .context("--marketplace-only refused this tree")?;
85 }
86
87 let signature = key.map(|key| sign_manifest(key, &manifest)).transpose()?;
88 let signed = SignedBundleManifest {
89 manifest,
90 signature,
91 };
92
93 pack::write_tarball(&args.root, &signed, &args.out)
94 .with_context(|| format!("Failed to write {}", args.out.display()))?;
95
96 Ok(describe(&args.out, &signed, key))
97}
98
99fn sign_manifest(
100 key: &BundleSigningKey,
101 manifest: &systemprompt_manifest::services::bundle::ServicesBundleManifest,
102) -> Result<BundleSignature> {
103 let payload =
104 canonical_manifest_bytes(manifest).context("Manifest could not be canonicalised")?;
105 Ok(BundleSignature {
106 alg: BUNDLE_SIGNATURE_ALG.to_owned(),
107 key_id: key.key_id.clone(),
108 sig_b64: sign_with_seed(&key.seed, &payload),
109 })
110}
111
112fn describe(
113 out: &Path,
114 signed: &SignedBundleManifest,
115 key: Option<&BundleSigningKey>,
116) -> BundleOutcome {
117 BundleOutcome {
118 archive: out.display().to_string(),
119 version: signed.manifest.version.clone(),
120 requires_core: signed.manifest.requires_core.clone(),
121 content_hash: signed.manifest.content_hash.clone(),
122 files: signed.manifest.files.len(),
123 total_size: signed.manifest.total_size,
124 dirs: signed.manifest.owns.dirs.join(", "),
125 signed_by: signed.signature.as_ref().map(|s| s.key_id.clone()),
126 public_key: key.map(|k| k.public_key.clone()),
127 }
128}
129
130pub fn requires_core(core_version: &str) -> Result<String> {
131 let parsed = semver::Version::parse(core_version)
132 .with_context(|| format!("Core version {core_version} is not semver"))?;
133 Ok(format!(">={}.{}", parsed.major, parsed.minor))
134}