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