1use std::collections::HashMap;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context, Result, anyhow, bail};
8use greentic_flow::flow_bundle::{blake3_hex, canonicalize_json, load_and_validate_bundle};
9use greentic_pack::PackKind;
10use greentic_pack::builder::{
11 ComponentArtifact, ComponentDescriptor, ComponentPin as PackComponentPin, DistributionSection,
12 FlowBundle as PackFlowBundle, ImportRef, NodeRef as PackNodeRef, PACK_VERSION, PackBuilder,
13 PackMeta, Provenance, Signing,
14};
15use greentic_pack::events::EventsSection;
16use greentic_pack::messaging::MessagingSection;
17use greentic_pack::repo::{InterfaceBinding, RepoPackSection};
18use semver::Version;
19use semver::VersionReq;
20use serde::Deserialize;
21use serde_json::{Value as JsonValue, json};
22use time::OffsetDateTime;
23use time::format_description::well_known::Rfc3339;
24
25use crate::component_resolver::{
26 ComponentResolver, NodeSchemaError, ResolvedComponent, ResolvedNode,
27};
28use crate::path_safety::normalize_under_root;
29
30#[derive(Debug, Clone, Copy)]
31pub enum PackSigning {
32 Dev,
33 None,
34}
35
36impl From<PackSigning> for Signing {
37 fn from(value: PackSigning) -> Self {
38 match value {
39 PackSigning::Dev => Signing::Dev,
40 PackSigning::None => Signing::None,
41 }
42 }
43}
44
45pub fn run(
46 flow_path: &Path,
47 output_path: &Path,
48 signing: PackSigning,
49 meta_path: Option<&Path>,
50 component_dir: Option<&Path>,
51) -> Result<()> {
52 let workspace_root = env::current_dir()
53 .context("failed to resolve workspace root")?
54 .canonicalize()
55 .context("failed to canonicalize workspace root")?;
56 let safe_flow = normalize_under_root(&workspace_root, flow_path)?;
57 let safe_meta = meta_path
58 .map(|path| normalize_under_root(&workspace_root, path))
59 .transpose()?;
60 let safe_component_dir = component_dir
61 .map(|dir| normalize_under_root(&workspace_root, dir))
62 .transpose()?;
63
64 build_once(
65 &safe_flow,
66 output_path,
67 signing,
68 safe_meta.as_deref(),
69 safe_component_dir.as_deref(),
70 )?;
71 if strict_mode_enabled() {
72 verify_determinism(
73 &safe_flow,
74 output_path,
75 signing,
76 safe_meta.as_deref(),
77 safe_component_dir.as_deref(),
78 )?;
79 }
80 Ok(())
81}
82
83fn build_once(
84 flow_path: &Path,
85 output_path: &Path,
86 signing: PackSigning,
87 meta_path: Option<&Path>,
88 component_dir: Option<&Path>,
89) -> Result<()> {
90 let flow_source = fs::read_to_string(flow_path)
91 .with_context(|| format!("failed to read {}", flow_path.display()))?;
92 let flow_doc_json: JsonValue = serde_yaml_bw::from_str(&flow_source).with_context(|| {
93 format!(
94 "failed to parse {} for node resolution",
95 flow_path.display()
96 )
97 })?;
98 let bundle = load_and_validate_bundle(&flow_source, Some(flow_path))
99 .with_context(|| format!("flow validation failed for {}", flow_path.display()))?;
100
101 let mut resolver = ComponentResolver::new(component_dir.map(PathBuf::from));
102 let mut resolved_nodes = Vec::new();
103 let mut schema_errors = Vec::new();
104
105 for node in &bundle.nodes {
106 if is_builtin_component(&node.component.name) {
107 if node.component.name == "component.exec"
108 && let Some(exec_node) =
109 resolve_component_exec_node(&mut resolver, node, &flow_doc_json)?
110 {
111 schema_errors.extend(resolver.validate_node(&exec_node)?);
112 resolved_nodes.push(exec_node);
113 }
114 continue;
115 }
116 let resolved = resolver.resolve_node(node, &flow_doc_json)?;
117 schema_errors.extend(resolver.validate_node(&resolved)?);
118 resolved_nodes.push(resolved);
119 }
120
121 if !schema_errors.is_empty() {
122 report_schema_errors(&schema_errors)?;
123 }
124
125 write_resolved_configs(&resolved_nodes)?;
126
127 let meta = load_pack_meta(meta_path, &bundle)?;
128 let mut builder = PackBuilder::new(meta)
129 .with_flow(to_pack_flow_bundle(&bundle, &flow_doc_json, &flow_source))
130 .with_signing(signing.into())
131 .with_provenance(build_provenance());
132
133 for artifact in collect_component_artifacts(&resolved_nodes) {
134 builder = builder.with_component(artifact);
135 }
136
137 if let Some(parent) = output_path.parent()
138 && !parent.as_os_str().is_empty()
139 {
140 fs::create_dir_all(parent)
141 .with_context(|| format!("failed to create {}", parent.display()))?;
142 }
143
144 let build_result = builder
145 .build(output_path)
146 .context("pack build failed (sign/build stage)")?;
147 println!(
148 "✓ Pack built at {} (manifest hash {})",
149 build_result.out_path.display(),
150 build_result.manifest_hash_blake3
151 );
152
153 Ok(())
154}
155
156fn strict_mode_enabled() -> bool {
157 matches!(
158 std::env::var("LOCAL_CHECK_STRICT")
159 .unwrap_or_default()
160 .as_str(),
161 "1" | "true" | "TRUE"
162 )
163}
164
165fn verify_determinism(
166 flow_path: &Path,
167 output_path: &Path,
168 signing: PackSigning,
169 meta_path: Option<&Path>,
170 component_dir: Option<&Path>,
171) -> Result<()> {
172 let temp_dir = tempfile::tempdir().context("failed to create tempdir for determinism check")?;
173 let temp_pack = temp_dir.path().join("deterministic.gtpack");
174 build_once(flow_path, &temp_pack, signing, meta_path, component_dir)
175 .context("determinism build failed")?;
176 let workspace_root = env::current_dir()
177 .context("failed to resolve workspace root")?
178 .canonicalize()
179 .context("failed to canonicalize workspace root")?;
180 let safe_output = normalize_under_root(&workspace_root, output_path)?;
181 let expected = fs::read(&safe_output).context("failed to read primary pack for determinism")?;
182 let actual = fs::read(&temp_pack).context("failed to read temp pack for determinism")?;
183 if expected != actual {
184 bail!("LOCAL_CHECK_STRICT detected non-deterministic pack output");
185 }
186 println!("LOCAL_CHECK_STRICT verified deterministic pack output");
187 Ok(())
188}
189
190fn to_pack_flow_bundle(
191 bundle: &greentic_flow::flow_bundle::FlowBundle,
192 flow_doc_json: &JsonValue,
193 flow_yaml: &str,
194) -> PackFlowBundle {
195 let canonical_json = canonicalize_json(flow_doc_json);
196
197 PackFlowBundle {
198 id: bundle.id.clone(),
199 kind: bundle.kind.clone(),
200 entry: bundle.entry.clone(),
201 yaml: flow_yaml.to_string(),
202 json: canonical_json.clone(),
203 hash_blake3: blake3_hex(
204 serde_json::to_vec(&canonical_json).expect("canonical flow JSON serialization"),
205 ),
206 nodes: bundle
207 .nodes
208 .iter()
209 .map(|node| PackNodeRef {
210 node_id: node.node_id.clone(),
211 component: PackComponentPin {
212 name: node.component.name.clone(),
213 version_req: node.component.version_req.clone(),
214 },
215 schema_id: node.schema_id.clone(),
216 })
217 .collect(),
218 }
219}
220
221fn write_resolved_configs(nodes: &[ResolvedNode]) -> Result<()> {
222 let root = Path::new(".greentic").join("resolved_config");
223 fs::create_dir_all(&root).context("failed to create .greentic/resolved_config")?;
224 for node in nodes {
225 let path = root.join(format!("{}.json", node.node_id));
226 let contents = serde_json::to_string_pretty(&json!({
227 "node_id": node.node_id,
228 "component": node.component.name,
229 "version": node.component.version.to_string(),
230 "config": node.config,
231 }))?;
232 fs::write(&path, contents)
233 .with_context(|| format!("failed to write {}", path.display()))?;
234 }
235 Ok(())
236}
237
238fn collect_component_artifacts(nodes: &[ResolvedNode]) -> Vec<ComponentArtifact> {
239 let mut map: HashMap<String, ComponentArtifact> = HashMap::new();
240 for node in nodes {
241 let component = &node.component;
242 let key = format!("{}@{}", component.name, component.version);
243 map.entry(key).or_insert_with(|| to_artifact(component));
244 }
245 map.into_values().collect()
246}
247
248fn is_builtin_component(name: &str) -> bool {
249 name == "component.exec"
250 || name == "flow.call"
251 || name == "session.wait"
252 || name.starts_with("emit")
253}
254
255fn resolve_component_exec_node(
256 resolver: &mut ComponentResolver,
257 node: &greentic_flow::flow_bundle::NodeRef,
258 flow_doc_json: &JsonValue,
259) -> Result<Option<ResolvedNode>> {
260 let nodes = flow_doc_json
261 .get("nodes")
262 .and_then(|value| value.as_object())
263 .ok_or_else(|| anyhow!("flow document missing nodes map"))?;
264 let Some(node_value) = nodes.get(&node.node_id) else {
265 bail!("node {} missing from flow document", node.node_id);
266 };
267 let payload = node_value
268 .get("component.exec")
269 .ok_or_else(|| anyhow!("component.exec payload missing for node {}", node.node_id))?;
270 let component_ref = payload
271 .get("component")
272 .and_then(|value| value.as_str())
273 .ok_or_else(|| {
274 anyhow!(
275 "component.exec requires `component` for node {}",
276 node.node_id
277 )
278 })?;
279 let (name, version_req) = parse_component_ref(component_ref)?;
280 let resolved_component = resolver.resolve_component(&name, &version_req)?;
281 Ok(Some(ResolvedNode {
282 node_id: node.node_id.clone(),
283 component: resolved_component,
284 pointer: format!("/nodes/{}", node.node_id),
285 config: payload.clone(),
286 }))
287}
288
289fn parse_component_ref(raw: &str) -> Result<(String, VersionReq)> {
290 if let Some((name, ver)) = raw.split_once('@') {
291 let vr = VersionReq::parse(ver.trim())
292 .with_context(|| format!("invalid version requirement `{ver}`"))?;
293 Ok((name.trim().to_string(), vr))
294 } else {
295 Ok((raw.trim().to_string(), VersionReq::default()))
296 }
297}
298
299fn to_artifact(component: &Arc<ResolvedComponent>) -> ComponentArtifact {
300 let hash = component
301 .wasm_hash
302 .strip_prefix("blake3:")
303 .unwrap_or(&component.wasm_hash)
304 .to_string();
305 ComponentArtifact {
306 name: component.name.clone(),
307 version: component.version.clone(),
308 wasm_path: component.wasm_path.clone(),
309 schema_json: component.schema_json.clone(),
310 manifest_json: component.manifest_json.clone(),
311 capabilities: component.capabilities_json.clone(),
312 world: Some(component.world.clone()),
313 hash_blake3: Some(hash),
314 }
315}
316
317fn report_schema_errors(errors: &[NodeSchemaError]) -> Result<()> {
318 let mut message = String::new();
319 for err in errors {
320 message.push_str(&format!(
321 "- node `{}` ({}) {}: {}\n",
322 err.node_id, err.component, err.pointer, err.message
323 ));
324 }
325 bail!("component schema validation failed:\n{message}");
326}
327
328fn load_pack_meta(
329 meta_path: Option<&Path>,
330 bundle: &greentic_flow::flow_bundle::FlowBundle,
331) -> Result<PackMeta> {
332 let config = if let Some(path) = meta_path {
333 let raw = fs::read_to_string(path)
334 .with_context(|| format!("failed to read {}", path.display()))?;
335 toml::from_str::<PackMetaToml>(&raw)
336 .with_context(|| format!("invalid pack metadata {}", path.display()))?
337 } else {
338 PackMetaToml::default()
339 };
340
341 let pack_id = config
342 .pack_id
343 .unwrap_or_else(|| format!("dev.local.{}", bundle.id));
344 let version = config
345 .version
346 .as_deref()
347 .unwrap_or("0.1.0")
348 .parse::<Version>()
349 .context("invalid pack version in metadata")?;
350 let pack_version = config.pack_version.unwrap_or(PACK_VERSION);
351 let name = config.name.unwrap_or_else(|| bundle.id.clone());
352 let description = config.description;
353 let authors = config.authors.unwrap_or_default();
354 let license = config.license;
355 let homepage = config.homepage;
356 let support = config.support;
357 let vendor = config.vendor;
358 let kind = config.kind;
359 let events = config.events;
360 let repo = config.repo;
361 let messaging = config.messaging;
362 let interfaces = config.interfaces.unwrap_or_default();
363 let imports = config
364 .imports
365 .unwrap_or_default()
366 .into_iter()
367 .map(|imp| ImportRef {
368 pack_id: imp.pack_id,
369 version_req: imp.version_req,
370 })
371 .collect();
372 let entry_flows = config
373 .entry_flows
374 .unwrap_or_else(|| vec![bundle.id.clone()]);
375 let created_at_utc = config.created_at_utc.unwrap_or_else(|| {
376 OffsetDateTime::now_utc()
377 .format(&Rfc3339)
378 .unwrap_or_default()
379 });
380 let annotations = config.annotations.map(toml_to_json_map).unwrap_or_default();
381 let distribution = config.distribution;
382 let components = config.components.unwrap_or_default();
383
384 Ok(PackMeta {
385 pack_version,
386 pack_id,
387 version,
388 name,
389 description,
390 authors,
391 license,
392 homepage,
393 support,
394 vendor,
395 imports,
396 kind,
397 entry_flows,
398 created_at_utc,
399 events,
400 repo,
401 messaging,
402 interfaces,
403 annotations,
404 distribution,
405 components,
406 })
407}
408
409fn toml_to_json_map(table: toml::value::Table) -> serde_json::Map<String, JsonValue> {
410 table
411 .into_iter()
412 .map(|(key, value)| {
413 let json_value: JsonValue = value.try_into().unwrap_or(JsonValue::Null);
414 (key, json_value)
415 })
416 .collect()
417}
418
419fn build_provenance() -> Provenance {
420 Provenance {
421 builder: format!("greentic-dev {}", env!("CARGO_PKG_VERSION")),
422 git_commit: git_rev().ok(),
423 git_repo: git_remote().ok(),
424 toolchain: None,
425 built_at_utc: OffsetDateTime::now_utc()
426 .format(&Rfc3339)
427 .unwrap_or_else(|_| "unknown".into()),
428 host: std::env::var("HOSTNAME").ok(),
429 notes: Some("Built via greentic-dev pack build".into()),
430 }
431}
432
433fn git_rev() -> Result<String> {
434 let output = std::process::Command::new("git")
435 .args(["rev-parse", "HEAD"])
436 .output()?;
437 if !output.status.success() {
438 bail!("git rev-parse failed");
439 }
440 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
441}
442
443fn git_remote() -> Result<String> {
444 let output = std::process::Command::new("git")
445 .args(["config", "--get", "remote.origin.url"])
446 .output()?;
447 if !output.status.success() {
448 bail!("git remote lookup failed");
449 }
450 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
451}
452
453#[derive(Debug, Deserialize, Default)]
454struct PackMetaToml {
455 pack_version: Option<u32>,
456 pack_id: Option<String>,
457 version: Option<String>,
458 name: Option<String>,
459 kind: Option<PackKind>,
460 description: Option<String>,
461 authors: Option<Vec<String>>,
462 license: Option<String>,
463 homepage: Option<String>,
464 support: Option<String>,
465 vendor: Option<String>,
466 entry_flows: Option<Vec<String>>,
467 events: Option<EventsSection>,
468 repo: Option<RepoPackSection>,
469 messaging: Option<MessagingSection>,
470 interfaces: Option<Vec<InterfaceBinding>>,
471 imports: Option<Vec<ImportToml>>,
472 annotations: Option<toml::value::Table>,
473 created_at_utc: Option<String>,
474 distribution: Option<DistributionSection>,
475 components: Option<Vec<ComponentDescriptor>>,
476}
477
478#[derive(Debug, Deserialize)]
479struct ImportToml {
480 pack_id: String,
481 version_req: String,
482}