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 workspace_root = env::current_dir()
166 .context("failed to resolve workspace root")?
167 .canonicalize()
168 .context("failed to canonicalize workspace root")?;
169 let safe_output = normalize_under_root(&workspace_root, output_path)?;
170 let expected = fs::read(&safe_output).context("failed to read primary pack for determinism")?;
171 let actual = fs::read(&temp_pack).context("failed to read temp pack for determinism")?;
172 if expected != actual {
173 bail!("LOCAL_CHECK_STRICT detected non-deterministic pack output");
174 }
175 println!("LOCAL_CHECK_STRICT verified deterministic pack output");
176 Ok(())
177}
178
179fn to_pack_flow_bundle(bundle: &greentic_flow::flow_bundle::FlowBundle) -> PackFlowBundle {
180 PackFlowBundle {
181 id: bundle.id.clone(),
182 kind: bundle.kind.clone(),
183 entry: bundle.entry.clone(),
184 yaml: bundle.yaml.clone(),
185 json: bundle.json.clone(),
186 hash_blake3: bundle.hash_blake3.clone(),
187 nodes: bundle
188 .nodes
189 .iter()
190 .map(|node| PackNodeRef {
191 node_id: node.node_id.clone(),
192 component: PackComponentPin {
193 name: node.component.name.clone(),
194 version_req: node.component.version_req.clone(),
195 },
196 schema_id: node.schema_id.clone(),
197 })
198 .collect(),
199 }
200}
201
202fn write_resolved_configs(nodes: &[ResolvedNode]) -> Result<()> {
203 let root = Path::new(".greentic").join("resolved_config");
204 fs::create_dir_all(&root).context("failed to create .greentic/resolved_config")?;
205 for node in nodes {
206 let path = root.join(format!("{}.json", node.node_id));
207 let contents = serde_json::to_string_pretty(&json!({
208 "node_id": node.node_id,
209 "component": node.component.name,
210 "version": node.component.version.to_string(),
211 "config": node.config,
212 }))?;
213 fs::write(&path, contents)
214 .with_context(|| format!("failed to write {}", path.display()))?;
215 }
216 Ok(())
217}
218
219fn collect_component_artifacts(nodes: &[ResolvedNode]) -> Vec<ComponentArtifact> {
220 let mut map: HashMap<String, ComponentArtifact> = HashMap::new();
221 for node in nodes {
222 let component = &node.component;
223 let key = format!("{}@{}", component.name, component.version);
224 map.entry(key).or_insert_with(|| to_artifact(component));
225 }
226 map.into_values().collect()
227}
228
229fn to_artifact(component: &Arc<ResolvedComponent>) -> ComponentArtifact {
230 let hash = component
231 .wasm_hash
232 .strip_prefix("blake3:")
233 .unwrap_or(&component.wasm_hash)
234 .to_string();
235 ComponentArtifact {
236 name: component.name.clone(),
237 version: component.version.clone(),
238 wasm_path: component.wasm_path.clone(),
239 schema_json: component.schema_json.clone(),
240 manifest_json: component.manifest_json.clone(),
241 capabilities: component.capabilities_json.clone(),
242 world: Some(component.world.clone()),
243 hash_blake3: Some(hash),
244 }
245}
246
247fn report_schema_errors(errors: &[NodeSchemaError]) -> Result<()> {
248 let mut message = String::new();
249 for err in errors {
250 message.push_str(&format!(
251 "- node `{}` ({}) {}: {}\n",
252 err.node_id, err.component, err.pointer, err.message
253 ));
254 }
255 bail!("component schema validation failed:\n{message}");
256}
257
258fn load_pack_meta(
259 meta_path: Option<&Path>,
260 bundle: &greentic_flow::flow_bundle::FlowBundle,
261) -> Result<PackMeta> {
262 let config = if let Some(path) = meta_path {
263 let raw = fs::read_to_string(path)
264 .with_context(|| format!("failed to read {}", path.display()))?;
265 toml::from_str::<PackMetaToml>(&raw)
266 .with_context(|| format!("invalid pack metadata {}", path.display()))?
267 } else {
268 PackMetaToml::default()
269 };
270
271 let pack_id = config
272 .pack_id
273 .unwrap_or_else(|| format!("dev.local.{}", bundle.id));
274 let version = config
275 .version
276 .as_deref()
277 .unwrap_or("0.1.0")
278 .parse::<Version>()
279 .context("invalid pack version in metadata")?;
280 let pack_version = config.pack_version.unwrap_or(PACK_VERSION);
281 let name = config.name.unwrap_or_else(|| bundle.id.clone());
282 let description = config.description;
283 let authors = config.authors.unwrap_or_default();
284 let license = config.license;
285 let homepage = config.homepage;
286 let support = config.support;
287 let vendor = config.vendor;
288 let kind = config.kind;
289 let events = config.events;
290 let repo = config.repo;
291 let messaging = config.messaging;
292 let interfaces = config.interfaces.unwrap_or_default();
293 let imports = config
294 .imports
295 .unwrap_or_default()
296 .into_iter()
297 .map(|imp| ImportRef {
298 pack_id: imp.pack_id,
299 version_req: imp.version_req,
300 })
301 .collect();
302 let entry_flows = config
303 .entry_flows
304 .unwrap_or_else(|| vec![bundle.id.clone()]);
305 let created_at_utc = config.created_at_utc.unwrap_or_else(|| {
306 OffsetDateTime::now_utc()
307 .format(&Rfc3339)
308 .unwrap_or_default()
309 });
310 let annotations = config.annotations.map(toml_to_json_map).unwrap_or_default();
311 let distribution = config.distribution;
312 let components = config.components.unwrap_or_default();
313
314 Ok(PackMeta {
315 pack_version,
316 pack_id,
317 version,
318 name,
319 description,
320 authors,
321 license,
322 homepage,
323 support,
324 vendor,
325 imports,
326 kind,
327 entry_flows,
328 created_at_utc,
329 events,
330 repo,
331 messaging,
332 interfaces,
333 annotations,
334 distribution,
335 components,
336 })
337}
338
339fn toml_to_json_map(table: toml::value::Table) -> serde_json::Map<String, JsonValue> {
340 table
341 .into_iter()
342 .map(|(key, value)| {
343 let json_value: JsonValue = value.try_into().unwrap_or(JsonValue::Null);
344 (key, json_value)
345 })
346 .collect()
347}
348
349fn build_provenance() -> Provenance {
350 Provenance {
351 builder: format!("greentic-dev {}", env!("CARGO_PKG_VERSION")),
352 git_commit: git_rev().ok(),
353 git_repo: git_remote().ok(),
354 toolchain: None,
355 built_at_utc: OffsetDateTime::now_utc()
356 .format(&Rfc3339)
357 .unwrap_or_else(|_| "unknown".into()),
358 host: std::env::var("HOSTNAME").ok(),
359 notes: Some("Built via greentic-dev pack build".into()),
360 }
361}
362
363fn git_rev() -> Result<String> {
364 let output = std::process::Command::new("git")
365 .args(["rev-parse", "HEAD"])
366 .output()?;
367 if !output.status.success() {
368 bail!("git rev-parse failed");
369 }
370 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
371}
372
373fn git_remote() -> Result<String> {
374 let output = std::process::Command::new("git")
375 .args(["config", "--get", "remote.origin.url"])
376 .output()?;
377 if !output.status.success() {
378 bail!("git remote lookup failed");
379 }
380 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
381}
382
383#[derive(Debug, Deserialize, Default)]
384struct PackMetaToml {
385 pack_version: Option<u32>,
386 pack_id: Option<String>,
387 version: Option<String>,
388 name: Option<String>,
389 kind: Option<PackKind>,
390 description: Option<String>,
391 authors: Option<Vec<String>>,
392 license: Option<String>,
393 homepage: Option<String>,
394 support: Option<String>,
395 vendor: Option<String>,
396 entry_flows: Option<Vec<String>>,
397 events: Option<EventsSection>,
398 repo: Option<RepoPackSection>,
399 messaging: Option<MessagingSection>,
400 interfaces: Option<Vec<InterfaceBinding>>,
401 imports: Option<Vec<ImportToml>>,
402 annotations: Option<toml::value::Table>,
403 created_at_utc: Option<String>,
404 distribution: Option<DistributionSection>,
405 components: Option<Vec<ComponentDescriptor>>,
406}
407
408#[derive(Debug, Deserialize)]
409struct ImportToml {
410 pack_id: String,
411 version_req: String,
412}