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;
11
12use crate::abi::{self, AbiError};
13use crate::cmd::component_world::{canonical_component_world, is_fallback_world};
14use crate::cmd::flow::{
15 FlowUpdateResult, manifest_component_id, resolve_operation, update_with_manifest,
16};
17use crate::config::{
18 ConfigInferenceOptions, ConfigSchemaSource, load_manifest_with_schema, resolve_manifest_path,
19};
20use crate::path_safety::normalize_under_root;
21
22const DEFAULT_MANIFEST: &str = "component.manifest.json";
23
24#[derive(Args, Debug, Clone)]
25pub struct BuildArgs {
26 #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
28 pub manifest: PathBuf,
29 #[arg(long = "cargo", value_name = "PATH")]
31 pub cargo_bin: Option<PathBuf>,
32 #[arg(long = "no-flow")]
34 pub no_flow: bool,
35 #[arg(long = "no-infer-config")]
37 pub no_infer_config: bool,
38 #[arg(long = "no-write-schema")]
40 pub no_write_schema: bool,
41 #[arg(long = "force-write-schema")]
43 pub force_write_schema: bool,
44 #[arg(long = "no-validate")]
46 pub no_validate: bool,
47 #[arg(long = "json")]
49 pub json: bool,
50}
51
52#[derive(Debug, serde::Serialize)]
53struct BuildSummary {
54 manifest: PathBuf,
55 wasm_path: PathBuf,
56 wasm_hash: String,
57 config_source: ConfigSchemaSource,
58 schema_written: bool,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 flows: Option<FlowUpdateResult>,
61}
62
63pub fn run(args: BuildArgs) -> Result<()> {
64 let manifest_path = resolve_manifest_path(&args.manifest);
65 let cwd = env::current_dir().context("failed to read current directory")?;
66 let manifest_path = if manifest_path.is_absolute() {
67 manifest_path
68 } else {
69 cwd.join(manifest_path)
70 };
71 if !manifest_path.exists() {
72 bail!("manifest not found at {}", manifest_path.display());
73 }
74 let cargo_bin = args
75 .cargo_bin
76 .clone()
77 .or_else(|| env::var_os("CARGO").map(PathBuf::from))
78 .unwrap_or_else(|| PathBuf::from("cargo"));
79 let inference_opts = ConfigInferenceOptions {
80 allow_infer: !args.no_infer_config,
81 write_schema: !args.no_write_schema,
82 force_write_schema: args.force_write_schema,
83 validate: !args.no_validate,
84 };
85 println!(
86 "Using manifest at {} (cargo: {})",
87 manifest_path.display(),
88 cargo_bin.display()
89 );
90
91 let config = load_manifest_with_schema(&manifest_path, &inference_opts)?;
92 let component_id = manifest_component_id(&config.manifest)?;
93 let _operation = resolve_operation(&config.manifest, component_id)?;
94 let flow_outcome = if args.no_flow {
95 None
96 } else {
97 Some(update_with_manifest(&config)?)
98 };
99
100 let mut manifest_to_write = flow_outcome
101 .as_ref()
102 .map(|outcome| outcome.manifest.clone())
103 .unwrap_or_else(|| config.manifest.clone());
104
105 let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
106 build_wasm(manifest_dir, &cargo_bin)?;
107 check_canonical_world_export(manifest_dir, &manifest_to_write)?;
108
109 if !config.persist_schema {
110 manifest_to_write
111 .as_object_mut()
112 .map(|obj| obj.remove("config_schema"));
113 }
114 let (wasm_path, wasm_hash) = update_manifest_hashes(manifest_dir, &mut manifest_to_write)?;
115 write_manifest(&manifest_path, &manifest_to_write)?;
116
117 if args.json {
118 let payload = BuildSummary {
119 manifest: manifest_path.clone(),
120 wasm_path,
121 wasm_hash,
122 config_source: config.source,
123 schema_written: config.schema_written && config.persist_schema,
124 flows: flow_outcome.as_ref().map(|outcome| outcome.result),
125 };
126 serde_json::to_writer_pretty(std::io::stdout(), &payload)?;
127 println!();
128 } else {
129 println!("Built wasm artifact at {}", wasm_path.display());
130 println!("Updated {} hashes (blake3)", manifest_path.display());
131 if config.schema_written && config.persist_schema {
132 println!(
133 "Updated {} with inferred config_schema ({:?})",
134 manifest_path.display(),
135 config.source
136 );
137 }
138 if let Some(outcome) = flow_outcome {
139 let flows = outcome.result;
140 println!(
141 "Flows updated (default: {}, custom: {})",
142 flows.default_updated, flows.custom_updated
143 );
144 } else {
145 println!("Flow regeneration skipped (--no-flow)");
146 }
147 }
148
149 Ok(())
150}
151
152fn build_wasm(manifest_dir: &Path, cargo_bin: &Path) -> Result<()> {
153 println!(
154 "Running cargo build via {} in {}",
155 cargo_bin.display(),
156 manifest_dir.display()
157 );
158 let mut cmd = Command::new(cargo_bin);
159 if let Some(flags) = resolved_wasm_rustflags() {
160 cmd.env("RUSTFLAGS", sanitize_wasm_rustflags(&flags));
161 }
162 let status = cmd
163 .arg("build")
164 .arg("--target")
165 .arg("wasm32-wasip2")
166 .arg("--release")
167 .current_dir(manifest_dir)
168 .status()
169 .with_context(|| format!("failed to run cargo build via {}", cargo_bin.display()))?;
170
171 if !status.success() {
172 bail!(
173 "cargo build --target wasm32-wasip2 --release failed with status {}",
174 status
175 );
176 }
177 Ok(())
178}
179
180fn resolved_wasm_rustflags() -> Option<String> {
182 env::var("WASM_RUSTFLAGS")
183 .ok()
184 .or_else(|| env::var("RUSTFLAGS").ok())
185}
186
187fn sanitize_wasm_rustflags(flags: &str) -> String {
189 flags
190 .replace("-Wl,", "")
191 .replace("-C link-arg=--no-keep-memory", "")
192 .replace("-C link-arg=--threads=1", "")
193 .split_whitespace()
194 .collect::<Vec<_>>()
195 .join(" ")
196}
197
198fn check_canonical_world_export(manifest_dir: &Path, manifest: &JsonValue) -> Result<()> {
199 if env::var_os("GREENTIC_SKIP_NODE_EXPORT_CHECK").is_some() {
200 println!("World export check skipped (GREENTIC_SKIP_NODE_EXPORT_CHECK=1)");
201 return Ok(());
202 }
203 let wasm_path = resolve_wasm_path(manifest_dir, manifest)?;
204 let canonical_world = canonical_component_world();
205 match abi::check_world_base(&wasm_path, canonical_world) {
206 Ok(exported) => println!("Exported world: {exported}"),
207 Err(err) => match err {
208 AbiError::WorldMismatch { expected, found } if is_fallback_world(&found) => {
209 println!("Exported world: fallback {found} (expected {expected})");
210 }
211 err => {
212 return Err(err)
213 .with_context(|| format!("component must export world {canonical_world}"));
214 }
215 },
216 }
217 Ok(())
218}
219
220fn update_manifest_hashes(
221 manifest_dir: &Path,
222 manifest: &mut JsonValue,
223) -> Result<(PathBuf, String)> {
224 let artifact_path = resolve_wasm_path(manifest_dir, manifest)?;
225 let wasm_bytes = fs::read(&artifact_path)
226 .with_context(|| format!("failed to read wasm at {}", artifact_path.display()))?;
227 let digest = blake3::hash(&wasm_bytes).to_hex().to_string();
228
229 manifest["artifacts"]["component_wasm"] =
230 JsonValue::String(path_string_relative(manifest_dir, &artifact_path)?);
231 manifest["hashes"]["component_wasm"] = JsonValue::String(format!("blake3:{digest}"));
232
233 Ok((artifact_path, format!("blake3:{digest}")))
234}
235
236fn path_string_relative(base: &Path, target: &Path) -> Result<String> {
237 let rel = pathdiff::diff_paths(target, base).unwrap_or_else(|| target.to_path_buf());
238 rel.to_str()
239 .map(|s| s.to_string())
240 .ok_or_else(|| anyhow!("failed to stringify path {}", target.display()))
241}
242
243fn resolve_wasm_path(manifest_dir: &Path, manifest: &JsonValue) -> Result<PathBuf> {
244 let manifest_root = manifest_dir
245 .canonicalize()
246 .with_context(|| format!("failed to canonicalize {}", manifest_dir.display()))?;
247 let candidate = manifest
248 .get("artifacts")
249 .and_then(|a| a.get("component_wasm"))
250 .and_then(|v| v.as_str())
251 .map(PathBuf::from)
252 .unwrap_or_else(|| {
253 let raw_name = manifest
254 .get("name")
255 .and_then(|v| v.as_str())
256 .or_else(|| manifest.get("id").and_then(|v| v.as_str()))
257 .unwrap_or("component");
258 let sanitized = raw_name.replace(['-', '.'], "_");
259 manifest_dir.join(format!("target/wasm32-wasip2/release/{sanitized}.wasm"))
260 });
261 if candidate.exists() {
262 let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
263 if candidate.is_absolute() {
264 candidate
265 .canonicalize()
266 .with_context(|| format!("failed to canonicalize {}", candidate.display()))
267 } else {
268 normalize_under_root(&manifest_root, &candidate)
269 }
270 })?;
271 return Ok(normalized);
272 }
273
274 if let Some(cargo_target_dir) = env::var_os("CARGO_TARGET_DIR") {
275 let relative = candidate
276 .strip_prefix(manifest_dir)
277 .unwrap_or(&candidate)
278 .to_path_buf();
279 if relative.starts_with("target") {
280 let alt =
281 PathBuf::from(cargo_target_dir).join(relative.strip_prefix("target").unwrap());
282 if alt.exists() {
283 return alt
284 .canonicalize()
285 .with_context(|| format!("failed to canonicalize {}", alt.display()));
286 }
287 }
288 }
289
290 let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
291 if candidate.is_absolute() {
292 candidate
293 .canonicalize()
294 .with_context(|| format!("failed to canonicalize {}", candidate.display()))
295 } else {
296 normalize_under_root(&manifest_root, &candidate)
297 }
298 })?;
299 Ok(normalized)
300}
301
302fn write_manifest(manifest_path: &Path, manifest: &JsonValue) -> Result<()> {
303 let formatted = serde_json::to_string_pretty(manifest)?;
304 fs::write(manifest_path, formatted + "\n")
305 .with_context(|| format!("failed to write {}", manifest_path.display()))
306}