fob_cli/commands/
build.rs1use crate::cli::BuildArgs;
7use crate::commands::utils;
8use crate::config::FobConfig;
9use crate::error::{BuildError, CliError, Result};
10use crate::ui;
11use fob_bundler::NativeRuntime;
12use std::path::Path;
13use std::sync::Arc;
14use std::time::Instant;
15
16pub async fn execute(args: BuildArgs) -> Result<()> {
39 let start_time = Instant::now();
40
41 ui::info("Loading configuration...");
43 let config = FobConfig::load(&args, None)?;
44 config.validate()?;
45
46 let cwd = utils::resolve_project_root(
48 config.cwd.as_deref(), config.entry.first().map(String::as_str), )?;
51
52 if config.clean {
54 let out_dir = utils::resolve_path(&config.out_dir, &cwd);
55 ui::info(&format!("Cleaning output directory: {}", out_dir.display()));
56 utils::clean_output_dir(&out_dir)?;
57 } else {
58 let out_dir = utils::resolve_path(&config.out_dir, &cwd);
59 utils::ensure_output_dir(&out_dir)?;
60 }
61
62 if config.entry.is_empty() {
64 return Err(CliError::InvalidArgument(
65 "At least one entry point is required".to_string(),
66 ));
67 }
68
69 for entry in &config.entry {
70 let entry_path = utils::resolve_path(std::path::Path::new(entry), &cwd);
71 utils::validate_entry(&entry_path)?;
72 }
73
74 build(&config, &cwd).await?;
76
77 let duration = start_time.elapsed();
78 ui::success(&format!(
79 "Build completed in {}",
80 ui::format_duration(duration)
81 ));
82
83 Ok(())
84}
85
86pub(crate) async fn build_with_result(
95 config: &FobConfig,
96 cwd: &std::path::Path,
97) -> Result<fob_bundler::BuildResult> {
98 validate_output_dir(&config.out_dir, cwd)?;
99
100 if config.entry.len() == 1 {
102 ui::info(&format!("Building: {}", config.entry[0]));
103 } else {
104 ui::info(&format!("Building {} entries...", config.entry.len()));
105 for entry in &config.entry {
106 ui::info(&format!(" - {}", entry));
107 }
108 }
109
110 let mode = if !config.bundle {
112 "library (externalize deps)"
113 } else if config.splitting && config.entry.len() > 1 {
114 "app (code splitting)"
115 } else if config.entry.len() > 1 {
116 "components (separate bundles)"
117 } else {
118 "standalone"
119 };
120 ui::info(&format!("Mode: {}", mode));
121 ui::info(&format!("Format: {:?}", config.format));
122 ui::info(&format!("Output: {}", config.out_dir.display()));
123
124 let mut builder = if !config.bundle {
131 if config.entry.len() == 1 {
133 fob_bundler::BuildOptions::new(&config.entry[0]).externalize_from("package.json")
134 } else {
135 fob_bundler::BuildOptions::new_multiple(&config.entry).externalize_from("package.json")
137 }
138 } else if config.splitting && config.entry.len() > 1 {
139 fob_bundler::BuildOptions::new_multiple(&config.entry)
141 .bundle_together()
142 .with_code_splitting()
143 } else if config.entry.len() > 1 {
144 fob_bundler::BuildOptions::new_multiple(&config.entry).bundle_separately()
146 } else {
147 fob_bundler::BuildOptions::new(&config.entry[0])
149 };
150
151 builder = builder
153 .format(convert_format(config.format))
154 .platform(convert_platform(config.platform))
155 .cwd(cwd)
156 .runtime(Arc::new(NativeRuntime));
157
158 if config.minify {
160 builder = builder.minify_level("identifiers");
161 }
162
163 if let Some(sourcemap_mode) = config.sourcemap {
165 builder = match sourcemap_mode {
166 crate::config::SourceMapMode::Inline => builder.sourcemap_inline(),
167 crate::config::SourceMapMode::External => builder.sourcemap(true),
168 crate::config::SourceMapMode::Hidden => builder.sourcemap_hidden(),
169 };
170 }
171
172 if !config.external.is_empty() {
174 builder = builder.externalize(&config.external);
175 }
176
177 if let Some(ref name) = config.global_name {
179 builder = builder.globals_map([("__self__".to_string(), name.clone())]);
180 }
181
182 #[cfg(feature = "dts-generation")]
184 {
185 if config.dts {
186 builder = builder.emit_dts(true);
187 }
188 }
189
190 let result = builder
192 .build()
193 .await
194 .map_err(|e| CliError::Build(BuildError::Custom(format!("Build failed: {}", e))))?;
195
196 let resolved_out_dir = utils::resolve_path(&config.out_dir, cwd);
198 result.write_to_force(&resolved_out_dir).map_err(|e| {
199 CliError::Build(BuildError::Custom(format!("Failed to write output: {}", e)))
200 })?;
201
202 ui::success(&format!("Built to {}", config.out_dir.display()));
203
204 Ok(result)
205}
206
207pub(crate) async fn build(config: &FobConfig, cwd: &std::path::Path) -> Result<()> {
214 build_with_result(config, cwd).await?;
215 Ok(())
216}
217
218fn validate_output_dir(out_dir: &Path, cwd: &Path) -> Result<()> {
231 let resolved_out_dir = utils::resolve_path(out_dir, cwd);
232 let canonical_out = if resolved_out_dir.exists() {
233 resolved_out_dir.canonicalize()?
234 } else {
235 let parent = resolved_out_dir.parent().ok_or_else(|| {
236 CliError::Build(BuildError::OutputNotWritable(resolved_out_dir.clone()))
237 })?;
238 let filename = resolved_out_dir.file_name().ok_or_else(|| {
239 CliError::Build(BuildError::OutputNotWritable(resolved_out_dir.clone()))
240 })?;
241 parent.canonicalize()?.join(filename)
242 };
243
244 let canonical_cwd = cwd.canonicalize()?;
245
246 let is_within_project = canonical_out.starts_with(&canonical_cwd);
247 let is_sibling = canonical_out
248 .parent()
249 .and_then(|p| canonical_cwd.parent().map(|c| p == c))
250 .unwrap_or(false);
251
252 if !is_within_project && !is_sibling {
253 return Err(CliError::Build(BuildError::OutputNotWritable(
254 resolved_out_dir,
255 )));
256 }
257
258 const DANGEROUS_PATHS: &[&str] = &[
259 "/bin",
260 "/boot",
261 "/dev",
262 "/etc",
263 "/lib",
264 "/lib64",
265 "/proc",
266 "/root",
267 "/sbin",
268 "/sys",
269 "/usr/bin",
270 "/usr/lib",
271 "/usr/sbin",
272 "/var/log",
273 ];
274
275 let out_str = canonical_out.to_string_lossy();
276 for dangerous in DANGEROUS_PATHS {
277 if out_str.starts_with(dangerous) {
278 return Err(CliError::Build(BuildError::Custom(format!(
279 "Refusing to write to system directory: {}",
280 out_str
281 ))));
282 }
283 }
284
285 if out_str == "/" {
286 return Err(CliError::Build(BuildError::Custom(
287 "Refusing to write to root directory".to_string(),
288 )));
289 }
290
291 Ok(())
292}
293
294fn convert_format(format: crate::config::Format) -> fob_bundler::OutputFormat {
296 match format {
297 crate::config::Format::Esm => fob_bundler::OutputFormat::Esm,
298 crate::config::Format::Cjs => fob_bundler::OutputFormat::Cjs,
299 crate::config::Format::Iife => fob_bundler::OutputFormat::Iife,
300 }
301}
302
303fn convert_platform(platform: crate::config::Platform) -> fob_bundler::Platform {
305 match platform {
306 crate::config::Platform::Browser => fob_bundler::Platform::Browser,
307 crate::config::Platform::Node => fob_bundler::Platform::Node,
308 }
309}