Skip to main content

systemprompt_cli/commands/core/services/
bundle.rs

1//! `services bundle` — pack a services tree into a signed archive.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}