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 ui::info(&format!("Bundle: {}", config.bundle));
110 ui::info(&format!("Format: {:?}", config.format));
111 if config.splitting {
112 ui::info("Code splitting: enabled");
113 }
114 ui::info(&format!("Output: {}", config.out_dir.display()));
115
116 let mut builder = if config.entry.len() == 1 {
118 fob_bundler::BuildOptions::new(&config.entry[0])
119 } else {
120 fob_bundler::BuildOptions::new_multiple(&config.entry)
121 };
122
123 builder = builder
125 .bundle(config.bundle)
126 .format(convert_format(config.format))
127 .minify(config.minify)
128 .platform(convert_platform(config.platform))
129 .splitting(config.splitting)
130 .cwd(cwd)
131 .runtime(Arc::new(NativeRuntime));
132
133 if let Some(sourcemap_mode) = config.sourcemap {
135 builder = match sourcemap_mode {
136 crate::config::SourceMapMode::Inline => builder.sourcemap_inline(),
137 crate::config::SourceMapMode::External => builder.sourcemap(true),
138 crate::config::SourceMapMode::Hidden => builder.sourcemap_hidden(),
139 };
140 }
141
142 if !config.external.is_empty() {
144 builder = builder.external(&config.external);
145 }
146
147 if let Some(ref name) = config.global_name {
149 builder = builder.globals_map([("__self__".to_string(), name.clone())]);
150 }
151
152 #[cfg(feature = "dts-generation")]
154 {
155 if config.dts {
156 builder = builder.emit_dts(true);
157 }
158 }
159
160 let result = builder
162 .build()
163 .await
164 .map_err(|e| CliError::Build(BuildError::Custom(format!("Build failed: {}", e))))?;
165
166 let resolved_out_dir = utils::resolve_path(&config.out_dir, cwd);
168 result.write_to_force(&resolved_out_dir).map_err(|e| {
169 CliError::Build(BuildError::Custom(format!("Failed to write output: {}", e)))
170 })?;
171
172 ui::success(&format!("Built to {}", config.out_dir.display()));
173
174 Ok(result)
175}
176
177pub(crate) async fn build(config: &FobConfig, cwd: &std::path::Path) -> Result<()> {
184 build_with_result(config, cwd).await?;
185 Ok(())
186}
187
188fn validate_output_dir(out_dir: &Path, cwd: &Path) -> Result<()> {
201 let resolved_out_dir = utils::resolve_path(out_dir, cwd);
202 let canonical_out = if resolved_out_dir.exists() {
203 resolved_out_dir.canonicalize()?
204 } else {
205 let parent = resolved_out_dir.parent().ok_or_else(|| {
206 CliError::Build(BuildError::OutputNotWritable(resolved_out_dir.clone()))
207 })?;
208 parent
209 .canonicalize()?
210 .join(resolved_out_dir.file_name().unwrap())
211 };
212
213 let canonical_cwd = cwd.canonicalize()?;
214
215 let is_within_project = canonical_out.starts_with(&canonical_cwd);
216 let is_sibling = canonical_out
217 .parent()
218 .and_then(|p| canonical_cwd.parent().map(|c| p == c))
219 .unwrap_or(false);
220
221 if !is_within_project && !is_sibling {
222 return Err(CliError::Build(BuildError::OutputNotWritable(
223 resolved_out_dir,
224 )));
225 }
226
227 const DANGEROUS_PATHS: &[&str] = &[
228 "/bin",
229 "/boot",
230 "/dev",
231 "/etc",
232 "/lib",
233 "/lib64",
234 "/proc",
235 "/root",
236 "/sbin",
237 "/sys",
238 "/usr/bin",
239 "/usr/lib",
240 "/usr/sbin",
241 "/var/log",
242 ];
243
244 let out_str = canonical_out.to_string_lossy();
245 for dangerous in DANGEROUS_PATHS {
246 if out_str.starts_with(dangerous) {
247 return Err(CliError::Build(BuildError::Custom(format!(
248 "Refusing to write to system directory: {}",
249 out_str
250 ))));
251 }
252 }
253
254 if out_str == "/" {
255 return Err(CliError::Build(BuildError::Custom(
256 "Refusing to write to root directory".to_string(),
257 )));
258 }
259
260 Ok(())
261}
262
263fn convert_format(format: crate::config::Format) -> fob_bundler::OutputFormat {
265 match format {
266 crate::config::Format::Esm => fob_bundler::OutputFormat::Esm,
267 crate::config::Format::Cjs => fob_bundler::OutputFormat::Cjs,
268 crate::config::Format::Iife => fob_bundler::OutputFormat::Iife,
269 }
270}
271
272fn convert_platform(platform: crate::config::Platform) -> fob_bundler::Platform {
274 match platform {
275 crate::config::Platform::Browser => fob_bundler::Platform::Browser,
276 crate::config::Platform::Node => fob_bundler::Platform::Node,
277 }
278}