1#![cfg(feature = "cli")]
2
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::Args;
10use serde_json::Value as JsonValue;
11use wasmtime::component::{Component, Linker, Val};
12use wasmtime::{Engine, Store};
13use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
14
15use crate::abi::{self, AbiError};
16use crate::cmd::component_world::{canonical_component_world, is_fallback_world};
17use crate::cmd::flow::{
18 FlowUpdateResult, manifest_component_id, resolve_operation, update_with_manifest,
19};
20use crate::cmd::i18n;
21use crate::config::{
22 ConfigInferenceOptions, ConfigSchemaSource, load_manifest_with_schema, resolve_manifest_path,
23};
24use crate::describe::from_wit_world;
25use crate::embedded_descriptor::embed_and_verify_wasm;
26use crate::parse_manifest;
27use crate::path_safety::normalize_under_root;
28use crate::schema_quality::{SchemaQualityMode, validate_operation_schemas};
29use greentic_types::cbor::canonical;
30use greentic_types::schemas::component::v0_6_0::ComponentDescribe;
31
32const DEFAULT_MANIFEST: &str = "component.manifest.json";
33
34#[derive(Args, Debug, Clone)]
35pub struct BuildArgs {
36 #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
38 pub manifest: PathBuf,
39 #[arg(long = "cargo", value_name = "PATH")]
41 pub cargo_bin: Option<PathBuf>,
42 #[arg(long = "no-flow")]
44 pub no_flow: bool,
45 #[arg(long = "no-infer-config")]
47 pub no_infer_config: bool,
48 #[arg(long = "no-write-schema")]
50 pub no_write_schema: bool,
51 #[arg(long = "force-write-schema")]
53 pub force_write_schema: bool,
54 #[arg(long = "no-validate")]
56 pub no_validate: bool,
57 #[arg(long = "json")]
59 pub json: bool,
60 #[arg(long)]
62 pub permissive: bool,
63}
64
65#[derive(Debug, serde::Serialize)]
66struct BuildSummary {
67 manifest: PathBuf,
68 wasm_path: PathBuf,
69 wasm_hash: String,
70 config_source: ConfigSchemaSource,
71 schema_written: bool,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 flows: Option<FlowUpdateResult>,
74}
75
76pub fn run(args: BuildArgs) -> Result<()> {
77 let manifest_path = resolve_manifest_path(&args.manifest);
78 let cwd = env::current_dir().context("failed to read current directory")?;
79 let manifest_path = if manifest_path.is_absolute() {
80 manifest_path
81 } else {
82 cwd.join(manifest_path)
83 };
84 if !manifest_path.exists() {
85 bail!(
86 "{}",
87 i18n::tr_lit("manifest not found at {}").replacen(
88 "{}",
89 &manifest_path.display().to_string(),
90 1
91 )
92 );
93 }
94 let cargo_bin = args
95 .cargo_bin
96 .clone()
97 .or_else(|| env::var_os("CARGO").map(PathBuf::from))
98 .unwrap_or_else(|| PathBuf::from("cargo"));
99 let inference_opts = ConfigInferenceOptions {
100 allow_infer: !args.no_infer_config,
101 write_schema: !args.no_write_schema,
102 force_write_schema: args.force_write_schema,
103 validate: !args.no_validate,
104 };
105 println!(
106 "Using manifest at {} (cargo: {})",
107 manifest_path.display(),
108 cargo_bin.display()
109 );
110
111 let config = load_manifest_with_schema(&manifest_path, &inference_opts)?;
112 let mode = if args.permissive {
113 SchemaQualityMode::Permissive
114 } else {
115 SchemaQualityMode::Strict
116 };
117 let manifest_component = parse_manifest(
118 &serde_json::to_string(&config.manifest)
119 .context("failed to serialize manifest for schema validation")?,
120 )
121 .context("failed to parse manifest for schema validation")?;
122 let schema_warnings = validate_operation_schemas(&manifest_component, mode)?;
123 for warning in schema_warnings {
124 eprintln!("warning[W_OP_SCHEMA_EMPTY]: {}", warning.message);
125 }
126 let component_id = manifest_component_id(&config.manifest)?;
127 let _operation = resolve_operation(&config.manifest, component_id)?;
128 let flow_outcome = if args.no_flow {
129 None
130 } else {
131 Some(update_with_manifest(&config)?)
132 };
133
134 let mut manifest_to_write = flow_outcome
135 .as_ref()
136 .map(|outcome| outcome.manifest.clone())
137 .unwrap_or_else(|| config.manifest.clone());
138 let canonical_manifest = parse_manifest(
139 &serde_json::to_string(&manifest_to_write)
140 .context("failed to serialize manifest for embedded descriptor")?,
141 )
142 .context("failed to parse canonical manifest for embedded descriptor")?;
143
144 let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
145 build_wasm(manifest_dir, &cargo_bin, &manifest_to_write)?;
146 check_canonical_world_export(manifest_dir, &manifest_to_write)?;
147 let wasm_path_for_embedding = resolve_wasm_path(manifest_dir, &manifest_to_write)?;
148 embed_and_verify_wasm(&wasm_path_for_embedding, &canonical_manifest)
149 .context("failed to embed canonical manifest into built wasm")?;
150
151 if !config.persist_schema {
152 manifest_to_write
153 .as_object_mut()
154 .map(|obj| obj.remove("config_schema"));
155 }
156 let (wasm_path, wasm_hash) = update_manifest_hashes(manifest_dir, &mut manifest_to_write)?;
157 emit_describe_artifacts(manifest_dir, &manifest_to_write, &wasm_path)?;
158 write_manifest(&manifest_path, &manifest_to_write)?;
159
160 if args.json {
161 let payload = BuildSummary {
162 manifest: manifest_path.clone(),
163 wasm_path,
164 wasm_hash,
165 config_source: config.source,
166 schema_written: config.schema_written && config.persist_schema,
167 flows: flow_outcome.as_ref().map(|outcome| outcome.result),
168 };
169 serde_json::to_writer_pretty(std::io::stdout(), &payload)?;
170 println!();
171 } else {
172 println!("Built wasm artifact at {}", wasm_path.display());
173 println!("Updated {} hashes (blake3)", manifest_path.display());
174 if config.schema_written && config.persist_schema {
175 println!(
176 "Updated {} with inferred config_schema ({:?})",
177 manifest_path.display(),
178 config.source
179 );
180 }
181 if let Some(outcome) = flow_outcome {
182 let flows = outcome.result;
183 println!(
184 "Flows updated (default: {}, custom: {})",
185 flows.default_updated, flows.custom_updated
186 );
187 } else {
188 println!("Flow regeneration skipped (--no-flow)");
189 }
190 }
191
192 Ok(())
193}
194
195fn build_wasm(manifest_dir: &Path, cargo_bin: &Path, manifest: &JsonValue) -> Result<()> {
196 let resolved_world = manifest.get("world").and_then(|v| v.as_str()).unwrap_or("");
197 if resolved_world.is_empty() {
198 println!("Resolved manifest world: <missing>");
199 } else {
200 println!("Resolved manifest world: {resolved_world}");
201 }
202 let require_component = resolved_world.contains("component@0.6.0");
203
204 if require_component {
205 if cargo_component_available(cargo_bin) {
206 println!(
207 "Running cargo component build via {} in {}",
208 cargo_bin.display(),
209 manifest_dir.display()
210 );
211 let mut cmd = Command::new(cargo_bin);
212 if let Some(flags) = resolved_wasm_rustflags() {
213 cmd.env("RUSTFLAGS", sanitize_wasm_rustflags(&flags));
214 }
215 let status = cmd
216 .arg("component")
217 .arg("build")
218 .arg("--target")
219 .arg("wasm32-wasip2")
220 .arg("--release")
221 .current_dir(manifest_dir)
222 .status()
223 .with_context(|| {
224 format!(
225 "failed to run cargo component build via {}",
226 cargo_bin.display()
227 )
228 })?;
229 if !status.success() {
230 bail!(
231 "cargo component build --target wasm32-wasip2 --release failed with status {}",
232 status
233 );
234 }
235 return Ok(());
236 }
237 bail!(
238 "component@0.6.0 manifests require cargo-component; install it with `cargo install cargo-component --locked`"
239 );
240 }
241
242 println!(
243 "Running cargo build via {} in {}",
244 cargo_bin.display(),
245 manifest_dir.display()
246 );
247 let mut cmd = Command::new(cargo_bin);
248 if let Some(flags) = resolved_wasm_rustflags() {
249 cmd.env("RUSTFLAGS", sanitize_wasm_rustflags(&flags));
250 }
251 let status = cmd
252 .arg("build")
253 .arg("--target")
254 .arg("wasm32-wasip2")
255 .arg("--release")
256 .current_dir(manifest_dir)
257 .status()
258 .with_context(|| format!("failed to run cargo build via {}", cargo_bin.display()))?;
259
260 if !status.success() {
261 bail!(
262 "cargo build --target wasm32-wasip2 --release failed with status {}",
263 status
264 );
265 }
266 Ok(())
267}
268
269fn cargo_component_available(cargo_bin: &Path) -> bool {
270 Command::new(cargo_bin)
271 .arg("component")
272 .arg("--version")
273 .status()
274 .map(|status| status.success())
275 .unwrap_or(false)
276}
277
278fn resolved_wasm_rustflags() -> Option<String> {
280 env::var("WASM_RUSTFLAGS")
281 .ok()
282 .or_else(|| env::var("RUSTFLAGS").ok())
283}
284
285fn sanitize_wasm_rustflags(flags: &str) -> String {
287 flags
288 .replace("-Wl,", "")
289 .replace("-C link-arg=--no-keep-memory", "")
290 .replace("-C link-arg=--threads=1", "")
291 .split_whitespace()
292 .collect::<Vec<_>>()
293 .join(" ")
294}
295
296fn check_canonical_world_export(manifest_dir: &Path, manifest: &JsonValue) -> Result<()> {
297 if env::var_os("GREENTIC_SKIP_NODE_EXPORT_CHECK").is_some() {
298 println!("World export check skipped (GREENTIC_SKIP_NODE_EXPORT_CHECK=1)");
299 return Ok(());
300 }
301 let wasm_path = resolve_wasm_path(manifest_dir, manifest)?;
302 let canonical_world = canonical_component_world();
303 match abi::check_world_base(&wasm_path, canonical_world) {
304 Ok(exported) => println!("Exported world: {exported}"),
305 Err(err) => match err {
306 AbiError::WorldMismatch { expected, found } if is_fallback_world(&found) => {
307 println!("Exported world: {expected} (compatible fallback export: {found})");
308 }
309 err => {
310 return Err(err)
311 .with_context(|| format!("component must export world {canonical_world}"));
312 }
313 },
314 }
315 Ok(())
316}
317
318fn update_manifest_hashes(
319 manifest_dir: &Path,
320 manifest: &mut JsonValue,
321) -> Result<(PathBuf, String)> {
322 let artifact_path = resolve_wasm_path(manifest_dir, manifest)?;
323 let wasm_bytes = fs::read(&artifact_path)
324 .with_context(|| format!("failed to read wasm at {}", artifact_path.display()))?;
325 let digest = blake3::hash(&wasm_bytes).to_hex().to_string();
326
327 manifest["artifacts"]["component_wasm"] =
328 JsonValue::String(path_string_relative(manifest_dir, &artifact_path)?);
329 manifest["hashes"]["component_wasm"] = JsonValue::String(format!("blake3:{digest}"));
330
331 Ok((artifact_path, format!("blake3:{digest}")))
332}
333
334fn path_string_relative(base: &Path, target: &Path) -> Result<String> {
335 let rel = pathdiff::diff_paths(target, base).unwrap_or_else(|| target.to_path_buf());
336 rel.to_str()
337 .map(|s| s.to_string())
338 .ok_or_else(|| anyhow!("failed to stringify path {}", target.display()))
339}
340
341fn resolve_wasm_path(manifest_dir: &Path, manifest: &JsonValue) -> Result<PathBuf> {
342 let manifest_root = manifest_dir
343 .canonicalize()
344 .with_context(|| format!("failed to canonicalize {}", manifest_dir.display()))?;
345 let candidate = manifest
346 .get("artifacts")
347 .and_then(|a| a.get("component_wasm"))
348 .and_then(|v| v.as_str())
349 .map(PathBuf::from)
350 .unwrap_or_else(|| {
351 let raw_name = manifest
352 .get("name")
353 .and_then(|v| v.as_str())
354 .or_else(|| manifest.get("id").and_then(|v| v.as_str()))
355 .unwrap_or("component");
356 let sanitized = raw_name.replace(['-', '.'], "_");
357 manifest_dir.join(format!("target/wasm32-wasip2/release/{sanitized}.wasm"))
358 });
359 if candidate.exists() {
360 let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
361 if candidate.is_absolute() {
362 candidate
363 .canonicalize()
364 .with_context(|| format!("failed to canonicalize {}", candidate.display()))
365 } else {
366 normalize_under_root(&manifest_root, &candidate)
367 }
368 })?;
369 return Ok(normalized);
370 }
371
372 if let Some(cargo_target_dir) = env::var_os("CARGO_TARGET_DIR") {
373 let relative = candidate
374 .strip_prefix(manifest_dir)
375 .unwrap_or(&candidate)
376 .to_path_buf();
377 if relative.starts_with("target") {
378 let alt =
379 PathBuf::from(cargo_target_dir).join(relative.strip_prefix("target").unwrap());
380 if alt.exists() {
381 return alt
382 .canonicalize()
383 .with_context(|| format!("failed to canonicalize {}", alt.display()));
384 }
385 }
386 }
387
388 let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
389 if candidate.is_absolute() {
390 candidate
391 .canonicalize()
392 .with_context(|| format!("failed to canonicalize {}", candidate.display()))
393 } else {
394 normalize_under_root(&manifest_root, &candidate)
395 }
396 })?;
397 Ok(normalized)
398}
399
400fn write_manifest(manifest_path: &Path, manifest: &JsonValue) -> Result<()> {
401 let formatted = serde_json::to_string_pretty(manifest)?;
402 fs::write(manifest_path, formatted + "\n")
403 .with_context(|| format!("failed to write {}", manifest_path.display()))
404}
405
406fn emit_describe_artifacts(
407 manifest_dir: &Path,
408 manifest: &JsonValue,
409 wasm_path: &Path,
410) -> Result<()> {
411 let abi_version = read_abi_version(manifest_dir);
412 let require_describe = abi_version.as_deref() == Some("0.6.0");
413 let manifest_model = parse_manifest(
414 &serde_json::to_string(manifest).context("failed to serialize manifest for describe")?,
415 )
416 .context("failed to parse manifest for describe")?;
417
418 let describe_bytes = match call_describe(wasm_path) {
419 Ok(bytes) => bytes,
420 Err(err) => {
421 if require_describe {
422 match from_wit_world(wasm_path, manifest_model.world.as_str()) {
423 Ok(payload) => {
424 write_wit_describe_artifacts(
425 manifest_dir,
426 manifest,
427 wasm_path,
428 abi_version.as_deref(),
429 &payload,
430 )?;
431 eprintln!(
432 "warning: describe export unavailable, emitted WIT-derived describe.json instead ({err})"
433 );
434 return Ok(());
435 }
436 Err(wit_err) => {
437 return Err(anyhow!(
438 "describe failed: {err}; WIT fallback failed: {wit_err}"
439 ));
440 }
441 }
442 }
443 eprintln!("warning: skipping describe artifacts ({err})");
444 return Ok(());
445 }
446 };
447
448 let payload = strip_self_describe_tag(&describe_bytes);
449 let canonical_bytes = canonical::canonicalize_allow_floats(payload)
450 .map_err(|err| anyhow!("describe canonicalization failed: {err}"))?;
451 let describe: ComponentDescribe = canonical::from_cbor(&canonical_bytes)
452 .map_err(|err| anyhow!("describe decode failed: {err}"))?;
453
454 let dist_dir = manifest_dir.join("dist");
455 fs::create_dir_all(&dist_dir)
456 .with_context(|| format!("failed to create {}", dist_dir.display()))?;
457
458 let (name, abi_underscore) = artifact_basename(manifest, wasm_path, abi_version.as_deref());
459 let base = format!("{name}__{abi_underscore}");
460 let describe_cbor_path = dist_dir.join(format!("{base}.describe.cbor"));
461 fs::write(&describe_cbor_path, &canonical_bytes)
462 .with_context(|| format!("failed to write {}", describe_cbor_path.display()))?;
463
464 let describe_json_path = dist_dir.join(format!("{base}.describe.json"));
465 let json = serde_json::to_string_pretty(&describe)?;
466 fs::write(&describe_json_path, json + "\n")
467 .with_context(|| format!("failed to write {}", describe_json_path.display()))?;
468
469 let wasm_out = dist_dir.join(format!("{base}.wasm"));
470 if wasm_out != wasm_path {
471 let _ = fs::copy(wasm_path, &wasm_out);
472 }
473
474 Ok(())
475}
476
477fn write_wit_describe_artifacts(
478 manifest_dir: &Path,
479 manifest: &JsonValue,
480 wasm_path: &Path,
481 abi_version: Option<&str>,
482 payload: &crate::describe::DescribePayload,
483) -> Result<()> {
484 let dist_dir = manifest_dir.join("dist");
485 fs::create_dir_all(&dist_dir)
486 .with_context(|| format!("failed to create {}", dist_dir.display()))?;
487
488 let (name, abi_underscore) = artifact_basename(manifest, wasm_path, abi_version);
489 let base = format!("{name}__{abi_underscore}");
490 let describe_json_path = dist_dir.join(format!("{base}.describe.json"));
491 let json = serde_json::to_string_pretty(payload)?;
492 fs::write(&describe_json_path, json + "\n")
493 .with_context(|| format!("failed to write {}", describe_json_path.display()))?;
494
495 let wasm_out = dist_dir.join(format!("{base}.wasm"));
496 if wasm_out != wasm_path {
497 let _ = fs::copy(wasm_path, &wasm_out);
498 }
499
500 Ok(())
501}
502
503fn read_abi_version(manifest_dir: &Path) -> Option<String> {
504 let cargo_path = manifest_dir.join("Cargo.toml");
505 let contents = fs::read_to_string(cargo_path).ok()?;
506 let doc: toml::Value = toml::from_str(&contents).ok()?;
507 doc.get("package")
508 .and_then(|pkg| pkg.get("metadata"))
509 .and_then(|meta| meta.get("greentic"))
510 .and_then(|g| g.get("abi_version"))
511 .and_then(|v| v.as_str())
512 .map(|s| s.to_string())
513}
514
515fn artifact_basename(
516 manifest: &JsonValue,
517 wasm_path: &Path,
518 abi_version: Option<&str>,
519) -> (String, String) {
520 let name = manifest
521 .get("name")
522 .and_then(|v| v.as_str())
523 .or_else(|| manifest.get("id").and_then(|v| v.as_str()))
524 .map(sanitize_name)
525 .unwrap_or_else(|| {
526 wasm_path
527 .file_stem()
528 .and_then(|s| s.to_str())
529 .map(sanitize_name)
530 .unwrap_or_else(|| "component".to_string())
531 });
532 let abi = abi_version.unwrap_or("0.6.0").replace('.', "_");
533 (name, abi)
534}
535
536fn sanitize_name(raw: &str) -> String {
537 raw.chars()
538 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
539 .collect::<String>()
540 .trim_matches('_')
541 .to_string()
542}
543
544fn call_describe(wasm_path: &Path) -> Result<Vec<u8>> {
545 let mut config = wasmtime::Config::new();
546 config.wasm_component_model(true);
547 let engine = Engine::new(&config).map_err(|err| anyhow!("failed to create engine: {err}"))?;
548 let component = Component::from_file(&engine, wasm_path)
549 .map_err(|err| anyhow!("failed to load component {}: {err}", wasm_path.display()))?;
550 let mut linker = Linker::new(&engine);
551 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
552 .map_err(|err| anyhow!("failed to add wasi: {err}"))?;
553 let mut store = Store::new(&engine, BuildWasi::new()?);
554 let instance = linker
555 .instantiate(&mut store, &component)
556 .map_err(|err| anyhow!("failed to instantiate component: {err}"))?;
557 let instance_index = resolve_interface_index(&instance, &mut store, "component-descriptor")
558 .ok_or_else(|| anyhow!("missing export interface component-descriptor"))?;
559 let func_index = instance
560 .get_export_index(&mut store, Some(&instance_index), "describe")
561 .ok_or_else(|| anyhow!("missing export component-descriptor.describe"))?;
562 let func = instance
563 .get_func(&mut store, func_index)
564 .ok_or_else(|| anyhow!("describe export is not callable"))?;
565 let mut results = vec![Val::Bool(false); func.ty(&mut store).results().len()];
566 func.call(&mut store, &[], &mut results)
567 .map_err(|err| anyhow!("describe call failed: {err}"))?;
568 let val = results
569 .first()
570 .ok_or_else(|| anyhow!("describe returned no value"))?;
571 val_to_bytes(val).map_err(|err| anyhow!(err))
572}
573
574fn resolve_interface_index(
575 instance: &wasmtime::component::Instance,
576 store: &mut Store<BuildWasi>,
577 interface: &str,
578) -> Option<wasmtime::component::ComponentExportIndex> {
579 for candidate in interface_candidates(interface) {
580 if let Some(index) = instance.get_export_index(&mut *store, None, &candidate) {
581 return Some(index);
582 }
583 }
584 None
585}
586
587fn interface_candidates(interface: &str) -> [String; 3] {
588 [
589 interface.to_string(),
590 format!("greentic:component/{interface}@0.6.0"),
591 format!("greentic:component/{interface}"),
592 ]
593}
594
595fn val_to_bytes(val: &Val) -> Result<Vec<u8>, String> {
596 match val {
597 Val::List(items) => {
598 let mut out = Vec::with_capacity(items.len());
599 for item in items {
600 match item {
601 Val::U8(byte) => out.push(*byte),
602 _ => return Err("expected list<u8>".to_string()),
603 }
604 }
605 Ok(out)
606 }
607 _ => Err("expected list<u8>".to_string()),
608 }
609}
610
611fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
612 const SELF_DESCRIBE_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7];
613 if bytes.starts_with(&SELF_DESCRIBE_TAG) {
614 &bytes[SELF_DESCRIBE_TAG.len()..]
615 } else {
616 bytes
617 }
618}
619
620struct BuildWasi {
621 ctx: WasiCtx,
622 table: ResourceTable,
623}
624
625impl BuildWasi {
626 fn new() -> Result<Self> {
627 let ctx = WasiCtxBuilder::new().build();
628 Ok(Self {
629 ctx,
630 table: ResourceTable::new(),
631 })
632 }
633}
634
635impl WasiView for BuildWasi {
636 fn ctx(&mut self) -> WasiCtxView<'_> {
637 WasiCtxView {
638 ctx: &mut self.ctx,
639 table: &mut self.table,
640 }
641 }
642}