Skip to main content

harn_cli/commands/
pack.rs

1//! `harn pack <entrypoint>` — build a signed-ready `.harnpack` from a
2//! Harn entrypoint.
3//!
4//! Walks the entrypoint's transitive imports, precompiles every module
5//! into a `.harnbc` artifact, snapshots the provider catalog and
6//! stdlib pin, generates a minimal SBOM, assembles a v2 `WorkflowBundle`
7//! manifest, and emits a deterministic tar.zst archive. Signing lands
8//! in E6.3; SBOM body and CLI `--json` polish in E6.4.
9
10use std::collections::BTreeMap;
11use std::path::{Component, Path, PathBuf};
12use std::process;
13
14use harn_parser::DiagnosticSeverity;
15use harn_vm::bytecode_cache;
16use harn_vm::module_artifact;
17use harn_vm::orchestration::{
18    build_harnpack, current_provider_catalog_hash_blake3, load_workflow_bundle_any_version,
19    workflow_bundle_hash, CatchupPolicySpec, ConnectorRequirement, EnvironmentRequirements,
20    HarnpackEntry, ModuleEntry, RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry,
21    WorkflowBundle, WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
22    WORKFLOW_BUNDLE_SCHEMA_VERSION,
23};
24use harn_vm::Compiler;
25use serde::Serialize;
26
27use crate::cli::PackArgs;
28use crate::command_error;
29use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput};
30use crate::parse_source_file;
31
32/// Stable schema version for the `harn pack --json` envelope. Bump when
33/// [`PackJsonData`] changes shape in a way that agents need to detect.
34pub const PACK_SCHEMA_VERSION: u32 = 1;
35
36/// JSON payload emitted under `JsonEnvelope.data` for `harn pack`.
37#[derive(Debug, Clone, Serialize)]
38pub struct PackJsonData {
39    pub bundle_hash: String,
40    pub output_path: PathBuf,
41    pub size_bytes: u64,
42    pub manifest: WorkflowBundle,
43}
44
45struct PackJsonOutput(PackJsonData);
46
47impl JsonOutput for PackJsonOutput {
48    const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
49    type Data = PackJsonData;
50    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
51        JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
52    }
53}
54
55pub fn run(args: PackArgs) {
56    match build(&args) {
57        Ok(outcome) => {
58            if args.json {
59                let envelope = PackJsonOutput(outcome.json).into_envelope();
60                println!("{}", to_string_pretty(&envelope));
61            } else {
62                println!(
63                    "wrote {} ({} bytes, bundle_hash {})",
64                    outcome.output_path.display(),
65                    outcome.size_bytes,
66                    outcome.bundle_hash
67                );
68            }
69        }
70        Err(err) => {
71            if args.json {
72                let envelope: JsonEnvelope<PackJsonData> =
73                    JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
74                println!("{}", to_string_pretty(&envelope));
75                process::exit(1);
76            }
77            command_error(&err.message);
78        }
79    }
80}
81
82/// Programmatic entrypoint used by tests and other CLI command code
83/// that needs the JSON envelope without going through stdout.
84pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
85    match build(args) {
86        Ok(outcome) => PackJsonOutput(outcome.json).into_envelope(),
87        Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
88    }
89}
90
91/// Outcome of [`build`]. Used by tests; the dispatcher consumes it
92/// directly via [`run`].
93pub struct PackOutcome {
94    pub bundle_hash: String,
95    pub output_path: PathBuf,
96    pub size_bytes: u64,
97    pub json: PackJsonData,
98}
99
100#[derive(Debug)]
101pub struct PackError {
102    pub code: &'static str,
103    pub message: String,
104}
105
106impl std::fmt::Display for PackError {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(f, "{}: {}", self.code, self.message)
109    }
110}
111
112impl std::error::Error for PackError {}
113
114impl PackError {
115    fn new(code: &'static str, message: impl Into<String>) -> Self {
116        Self {
117            code,
118            message: message.into(),
119        }
120    }
121}
122
123pub fn build(args: &PackArgs) -> Result<PackOutcome, PackError> {
124    if let Some(upgrade) = &args.upgrade {
125        if !upgrade.exists() {
126            return Err(PackError::new(
127                "upgrade.not_found",
128                format!(
129                    "--upgrade source bundle does not exist: {}",
130                    upgrade.display()
131                ),
132            ));
133        }
134    }
135    let entrypoint = args
136        .entrypoint
137        .canonicalize()
138        .unwrap_or_else(|_| args.entrypoint.clone());
139    if !entrypoint.exists() {
140        return Err(PackError::new(
141            "entrypoint.not_found",
142            format!("entrypoint does not exist: {}", args.entrypoint.display()),
143        ));
144    }
145    if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
146    {
147        return Err(PackError::new(
148            "entrypoint.invalid",
149            format!(
150                "entrypoint must be a .harn file: {}",
151                args.entrypoint.display()
152            ),
153        ));
154    }
155    let project_root = entrypoint
156        .parent()
157        .map(Path::to_path_buf)
158        .unwrap_or_else(|| PathBuf::from("."));
159    let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
160        PackError::new(
161            "entrypoint.outside_root",
162            format!(
163                "entrypoint {} could not be relativized against {}",
164                entrypoint.display(),
165                project_root.display()
166            ),
167        )
168    })?;
169
170    let prior = match &args.upgrade {
171        Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
172            PackError::new(
173                "upgrade.read_failed",
174                format!("failed to read --upgrade source {}: {err}", path.display()),
175            )
176        })?),
177        None => None,
178    };
179
180    let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
181    let mut module_paths = graph.module_paths();
182    // The entrypoint is always present in the graph; ensure deterministic order.
183    module_paths.sort();
184
185    let mut transitive_modules = Vec::new();
186    let mut contents = Vec::new();
187    let mut sbom_packages = Vec::new();
188    let mut sbom_relationships = Vec::new();
189
190    let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
191    let harn_version = bytecode_cache::HARN_VERSION.to_string();
192
193    sbom_packages.push(SBOMPackage {
194        name: "harn-stdlib".to_string(),
195        version: Some(stdlib_version.clone()),
196        package_hash_blake3: None,
197        license: None,
198    });
199
200    for module_path in &module_paths {
201        let module_str = module_path.to_string_lossy().to_string();
202        if module_str.starts_with("<std>/") {
203            let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
204            sbom_packages.push(SBOMPackage {
205                name: format!("std/{stdlib_name}"),
206                version: Some(stdlib_version.clone()),
207                package_hash_blake3: None,
208                license: None,
209            });
210            sbom_relationships.push(SBOMRelationship {
211                from: format!("entrypoint:{}", entrypoint_rel.display()),
212                to: format!("std/{stdlib_name}"),
213                relationship_type: "depends_on".to_string(),
214            });
215            continue;
216        }
217
218        let source = std::fs::read_to_string(module_path).map_err(|err| {
219            PackError::new(
220                "module.read_failed",
221                format!("failed to read {}: {err}", module_path.display()),
222            )
223        })?;
224
225        let (parsed_source, program) = parse_source_file(&module_str);
226        debug_assert_eq!(parsed_source, source);
227        type_check_or_fail(&source, &module_str, &program)?;
228
229        let entry_chunk = Compiler::new().compile(&program).map_err(|err| {
230            PackError::new(
231                "module.compile_failed",
232                format!("compile error in {}: {err}", module_path.display()),
233            )
234        })?;
235
236        let module_artifact_opt =
237            module_artifact::compile_module_artifact(&program, Some(module_str.clone())).ok();
238
239        let cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
240        let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&cache_key, &entry_chunk)
241            .map_err(|err| {
242                PackError::new(
243                    "module.serialize_failed",
244                    format!(
245                        "failed to serialize chunk for {}: {err}",
246                        module_path.display()
247                    ),
248                )
249            })?;
250
251        let module_artifact_bytes = match module_artifact_opt.as_ref() {
252            Some(artifact) => Some(
253                bytecode_cache::serialize_module_artifact(&cache_key, artifact).map_err(|err| {
254                    PackError::new(
255                        "module.serialize_failed",
256                        format!(
257                            "failed to serialize module artifact for {}: {err}",
258                            module_path.display()
259                        ),
260                    )
261                })?,
262            ),
263            None => None,
264        };
265
266        let rel = relativize(&project_root, module_path).unwrap_or_else(|| {
267            PathBuf::from(
268                module_path
269                    .file_name()
270                    .map(|name| name.to_string_lossy().into_owned())
271                    .unwrap_or_else(|| module_str.clone()),
272            )
273        });
274        let source_archive_path = PathBuf::from("sources").join(&rel);
275        let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
276            .ok_or_else(|| {
277                PackError::new(
278                    "module.invalid_path",
279                    format!("module path has no stem: {}", module_path.display()),
280                )
281            })?;
282        let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
283
284        let source_hash = blake3_hash(source.as_bytes());
285        let harnbc_hash = blake3_hash(&chunk_bytes);
286
287        transitive_modules.push(ModuleEntry {
288            path: rel.clone(),
289            source_hash_blake3: source_hash.clone(),
290            harnbc_hash_blake3: harnbc_hash.clone(),
291        });
292
293        contents.push(HarnpackEntry::new(
294            source_archive_path,
295            source.as_bytes().to_vec(),
296        ));
297        contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
298        if let Some(artifact_bytes) = module_artifact_bytes {
299            let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
300                .ok_or_else(|| {
301                    PackError::new(
302                        "module.invalid_path",
303                        format!("module path has no stem: {}", module_path.display()),
304                    )
305                })?;
306            let module_archive_path = PathBuf::from("bytecode").join(module_rel);
307            contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
308        }
309
310        if module_path != &entrypoint {
311            sbom_relationships.push(SBOMRelationship {
312                from: format!("entrypoint:{}", entrypoint_rel.display()),
313                to: format!("module:{}", rel.display()),
314                relationship_type: "depends_on".to_string(),
315            });
316        }
317        sbom_packages.push(SBOMPackage {
318            name: format!("module:{}", rel.display()),
319            version: Some(harn_version.clone()),
320            package_hash_blake3: Some(source_hash),
321            license: None,
322        });
323    }
324
325    if transitive_modules.is_empty() {
326        return Err(PackError::new(
327            "pack.no_modules",
328            format!(
329                "no Harn modules resolved from entrypoint {}",
330                entrypoint.display()
331            ),
332        ));
333    }
334
335    let provider_catalog_hash = current_provider_catalog_hash_blake3().map_err(|err| {
336        PackError::new(
337            "provider_catalog.failed",
338            format!("failed to snapshot provider catalog: {err}"),
339        )
340    })?;
341
342    // E6.4 populates `tool_manifest` from the static module walk; today
343    // it stays empty so the v2 manifest is consistently deterministic.
344    let tool_manifest: Vec<ToolEntry> = Vec::new();
345    let bundle = assemble_bundle(
346        &entrypoint_rel,
347        transitive_modules,
348        stdlib_version,
349        harn_version,
350        provider_catalog_hash,
351        tool_manifest,
352        SBOMDoc {
353            format: "spdx-lite".to_string(),
354            version: "2.3".to_string(),
355            packages: sbom_packages,
356            relationships: sbom_relationships,
357        },
358        prior.as_ref(),
359    );
360
361    let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
362        PackError::new(
363            "pack.archive_failed",
364            format!("failed to assemble .harnpack archive: {err}"),
365        )
366    })?;
367    let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
368        PackError::new(
369            "pack.hash_failed",
370            format!("failed to compute bundle hash: {err}"),
371        )
372    })?;
373
374    let output_path = resolve_output_path(&args.out, &entrypoint);
375    if let Some(parent) = output_path.parent() {
376        if !parent.as_os_str().is_empty() {
377            std::fs::create_dir_all(parent).map_err(|err| {
378                PackError::new(
379                    "pack.output_dir_failed",
380                    format!("failed to create output dir {}: {err}", parent.display()),
381                )
382            })?;
383        }
384    }
385    std::fs::write(&output_path, &archive_bytes).map_err(|err| {
386        PackError::new(
387            "pack.write_failed",
388            format!("failed to write {}: {err}", output_path.display()),
389        )
390    })?;
391    let size_bytes = archive_bytes.len() as u64;
392
393    Ok(PackOutcome {
394        bundle_hash: bundle_hash.clone(),
395        output_path: output_path.clone(),
396        size_bytes,
397        json: PackJsonData {
398            bundle_hash,
399            output_path,
400            size_bytes,
401            manifest: bundle,
402        },
403    })
404}
405
406fn assemble_bundle(
407    entrypoint_rel: &Path,
408    transitive_modules: Vec<ModuleEntry>,
409    stdlib_version: String,
410    harn_version: String,
411    provider_catalog_hash: String,
412    tool_manifest: Vec<ToolEntry>,
413    sbom: SBOMDoc,
414    prior: Option<&WorkflowBundle>,
415) -> WorkflowBundle {
416    let stem = entrypoint_rel
417        .file_stem()
418        .map(|s| s.to_string_lossy().into_owned())
419        .unwrap_or_else(|| "harnpack".to_string());
420
421    let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
422        id: stem.clone(),
423        name: Some(stem.clone()),
424        version: "0.0.0".to_string(),
425        workflow: degenerate_workflow(&stem),
426        triggers: vec![WorkflowBundleTrigger {
427            id: "manual".to_string(),
428            kind: "manual".to_string(),
429            node_id: Some("entry".to_string()),
430            ..WorkflowBundleTrigger::default()
431        }],
432        policy: WorkflowBundlePolicy {
433            autonomy_tier: "act_with_approval".to_string(),
434            tool_policy: BTreeMap::new(),
435            approval_required: Vec::new(),
436            retry: RetryPolicySpec {
437                max_attempts: 1,
438                backoff: "none".to_string(),
439            },
440            catchup: CatchupPolicySpec {
441                mode: "none".to_string(),
442                max_events: None,
443            },
444        },
445        connectors: Vec::<ConnectorRequirement>::new(),
446        environment: EnvironmentRequirements::default(),
447        receipts: WorkflowBundleReplayMetadata::default(),
448        ..WorkflowBundle::default()
449    });
450
451    bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
452    bundle.entrypoint = entrypoint_rel.to_path_buf();
453    bundle.transitive_modules = transitive_modules;
454    bundle.stdlib_version = stdlib_version;
455    bundle.harn_version = harn_version;
456    bundle.provider_catalog_hash = provider_catalog_hash;
457    bundle.tool_manifest = tool_manifest;
458    bundle.sbom = sbom;
459    bundle.signature = None;
460    bundle
461}
462
463fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
464    use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
465    let mut nodes = BTreeMap::new();
466    nodes.insert(
467        "entry".to_string(),
468        WorkflowNode {
469            id: Some("entry".to_string()),
470            kind: "action".to_string(),
471            task_label: Some(stem.to_string()),
472            ..WorkflowNode::default()
473        },
474    );
475    WorkflowGraph {
476        type_name: "workflow_graph".to_string(),
477        id: format!("{stem}_pack"),
478        name: Some(stem.to_string()),
479        version: 1,
480        entry: "entry".to_string(),
481        nodes,
482        ..WorkflowGraph::default()
483    }
484}
485
486fn type_check_or_fail(
487    source: &str,
488    path: &str,
489    program: &[harn_parser::SNode],
490) -> Result<(), PackError> {
491    let mut had_error = false;
492    let mut messages = String::new();
493    for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
494        let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
495        if matches!(diag.severity, DiagnosticSeverity::Error) {
496            had_error = true;
497        }
498        messages.push_str(&rendered);
499    }
500    if had_error {
501        return Err(PackError::new(
502            "module.type_error",
503            format!("type errors in {path}:\n{messages}"),
504        ));
505    }
506    if !messages.is_empty() {
507        eprint!("{messages}");
508    }
509    Ok(())
510}
511
512fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
513    let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
514    let target_canon = target
515        .canonicalize()
516        .unwrap_or_else(|_| target.to_path_buf());
517    if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
518        return Some(rel.to_path_buf());
519    }
520    // Fallback: keep the filename so deeply-nested entrypoints still
521    // pack as relative paths.
522    target.file_name().map(PathBuf::from)
523}
524
525fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
526    let stem = rel.file_stem()?.to_string_lossy().into_owned();
527    if stem.is_empty() {
528        return None;
529    }
530    let parent_components: Vec<Component<'_>> = rel
531        .parent()
532        .map(|p| p.components().collect())
533        .unwrap_or_default();
534    let mut adjacent = PathBuf::new();
535    for component in parent_components {
536        adjacent.push(component.as_os_str());
537    }
538    let mut filename = stem;
539    filename.push('.');
540    filename.push_str(extension);
541    adjacent.push(filename);
542    Some(adjacent)
543}
544
545fn blake3_hash(bytes: &[u8]) -> String {
546    format!("blake3:{}", blake3::hash(bytes))
547}
548
549fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
550    if let Some(path) = out {
551        return path.clone();
552    }
553    let stem = entrypoint
554        .file_stem()
555        .map(|s| s.to_string_lossy().into_owned())
556        .unwrap_or_else(|| "bundle".to_string());
557    let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
558    parent.join(format!("{stem}.harnpack"))
559}