1use serde_json::Value;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum JsPackageManager {
15 Bun,
16 Pnpm,
17 Yarn,
18 Npm,
19}
20
21impl JsPackageManager {
22 pub fn name(self) -> &'static str {
23 match self {
24 Self::Bun => "bun",
25 Self::Pnpm => "pnpm",
26 Self::Yarn => "yarn",
27 Self::Npm => "npm",
28 }
29 }
30
31 pub fn bin(self) -> &'static str {
33 match self {
34 Self::Bun => {
35 if cfg!(windows) {
36 "bun.exe"
37 } else {
38 "bun"
39 }
40 }
41 Self::Pnpm => {
42 if cfg!(windows) {
43 "pnpm.cmd"
44 } else {
45 "pnpm"
46 }
47 }
48 Self::Yarn => {
49 if cfg!(windows) {
50 "yarn.cmd"
51 } else {
52 "yarn"
53 }
54 }
55 Self::Npm => {
56 if cfg!(windows) {
57 "npm.cmd"
58 } else {
59 "npm"
60 }
61 }
62 }
63 }
64
65 pub fn run_script_args(self, script: &str) -> Vec<String> {
67 match self {
68 Self::Yarn => vec!["run".into(), script.into()],
69 Self::Bun => vec!["run".into(), script.into()],
70 Self::Pnpm | Self::Npm => vec!["run".into(), script.into()],
71 }
72 }
73}
74
75pub fn detect_js_package_manager(dir: &Path) -> JsPackageManager {
83 if let Some(pm) = package_manager_from_package_json(&dir.join("package.json")) {
84 return pm;
85 }
86 let mut cur = dir.to_path_buf();
87 for _ in 0..4 {
88 if let Some(pm) = package_manager_from_package_json(&cur.join("package.json")) {
89 return pm;
90 }
91 if let Some(pm) = package_manager_from_lockfiles(&cur) {
92 return pm;
93 }
94 if !cur.pop() {
95 break;
96 }
97 }
98 package_manager_from_lockfiles(dir).unwrap_or(JsPackageManager::Npm)
99}
100
101pub fn detect_js_install_package_manager(dir: &Path) -> JsPackageManager {
115 let mut cur = dir.to_path_buf();
116 for _ in 0..4 {
117 if let Some(pm) = package_manager_from_lockfiles_strict(&cur) {
118 return pm;
119 }
120 if let Some(pm) = package_manager_from_package_json(&cur.join("package.json")) {
121 return pm;
122 }
123 if cur.join("pnpm-workspace.yaml").is_file() || cur.join("pnpm-workspace.yml").is_file() {
124 return JsPackageManager::Pnpm;
125 }
126 if !cur.pop() {
127 break;
128 }
129 }
130 package_manager_from_lockfiles_strict(dir)
131 .or_else(|| package_manager_from_package_json(&dir.join("package.json")))
132 .unwrap_or(JsPackageManager::Npm)
133}
134
135fn package_manager_from_lockfiles_strict(dir: &Path) -> Option<JsPackageManager> {
137 if dir.join("bun.lockb").is_file() || dir.join("bun.lock").is_file() {
138 return Some(JsPackageManager::Bun);
139 }
140 if dir.join("pnpm-lock.yaml").is_file() {
141 return Some(JsPackageManager::Pnpm);
142 }
143 if dir.join("yarn.lock").is_file() {
144 return Some(JsPackageManager::Yarn);
145 }
146 if dir.join("package-lock.json").is_file() || dir.join("npm-shrinkwrap.json").is_file() {
147 return Some(JsPackageManager::Npm);
148 }
149 None
150}
151
152fn package_manager_from_lockfiles(dir: &Path) -> Option<JsPackageManager> {
153 if let Some(pm) = package_manager_from_lockfiles_strict(dir) {
154 return Some(pm);
155 }
156 if dir.join("pnpm-workspace.yaml").is_file() || dir.join("pnpm-workspace.yml").is_file() {
157 return Some(JsPackageManager::Pnpm);
158 }
159 None
160}
161
162fn package_manager_from_package_json(path: &Path) -> Option<JsPackageManager> {
163 let content = fs::read_to_string(path).ok()?;
164 let json: Value = serde_json::from_str(&content).ok()?;
165 let package_manager = json.get("packageManager")?.as_str()?;
166 let name = package_manager
167 .split('@')
168 .next()?
169 .trim()
170 .to_ascii_lowercase();
171 match name.as_str() {
172 "bun" => Some(JsPackageManager::Bun),
173 "pnpm" => Some(JsPackageManager::Pnpm),
174 "yarn" => Some(JsPackageManager::Yarn),
175 "npm" => Some(JsPackageManager::Npm),
176 _ => None,
177 }
178}
179
180pub fn ensure_pnpm_workspace_packages(dir: &Path) -> Result<Vec<PathBuf>, String> {
183 let mut fixed = Vec::new();
184 let mut cur = dir.to_path_buf();
185 loop {
186 for name in ["pnpm-workspace.yaml", "pnpm-workspace.yml"] {
187 let path = cur.join(name);
188 if path.is_file() {
189 if fix_pnpm_workspace_packages_file(&path)? {
190 fixed.push(path);
191 }
192 }
193 }
194 if !cur.pop() {
195 break;
196 }
197 }
198 Ok(fixed)
199}
200
201fn dir_has_nested_package_json(root: &Path, child: &str) -> bool {
202 let base = root.join(child);
203 if !base.is_dir() {
204 return false;
205 }
206 let Ok(entries) = fs::read_dir(&base) else {
207 return false;
208 };
209 for entry in entries.flatten() {
210 let p = entry.path();
211 if p.is_dir() && p.join("package.json").is_file() {
212 return true;
213 }
214 }
215 false
216}
217
218fn default_packages_for_workspace_root(workspace_dir: &Path) -> Vec<String> {
219 let has_apps = dir_has_nested_package_json(workspace_dir, "apps");
220 let has_packages = dir_has_nested_package_json(workspace_dir, "packages");
221 if has_apps || has_packages {
222 let mut pkgs = vec![".".to_string()];
223 if has_apps {
224 pkgs.push("apps/*".to_string());
225 }
226 if has_packages {
227 pkgs.push("packages/*".to_string());
228 }
229 pkgs
230 } else {
231 vec![".".to_string()]
233 }
234}
235
236fn fix_pnpm_workspace_packages_file(path: &Path) -> Result<bool, String> {
237 let raw = fs::read_to_string(path)
238 .map_err(|e| format!("read {}: {e}", path.display()))?;
239 let mut value: serde_yaml::Value = if raw.trim().is_empty() {
240 serde_yaml::Value::Mapping(serde_yaml::Mapping::new())
241 } else {
242 serde_yaml::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?
243 };
244
245 let packages_ok = value
246 .as_mapping()
247 .and_then(|m| m.get(serde_yaml::Value::String("packages".into())))
248 .and_then(|p| p.as_sequence())
249 .map(|s| !s.is_empty())
250 .unwrap_or(false);
251
252 if packages_ok {
253 return Ok(false);
254 }
255
256 let map = value
257 .as_mapping_mut()
258 .ok_or_else(|| format!("{} is not a YAML mapping", path.display()))?;
259
260 let workspace_dir = path.parent().unwrap_or_else(|| Path::new("."));
261 let default_list = default_packages_for_workspace_root(workspace_dir);
262 let default_packages = serde_yaml::Value::Sequence(
263 default_list
264 .into_iter()
265 .map(serde_yaml::Value::String)
266 .collect(),
267 );
268 map.insert(
269 serde_yaml::Value::String("packages".into()),
270 default_packages,
271 );
272
273 let out = serde_yaml::to_string(&value)
274 .map_err(|e| format!("serialize {}: {e}", path.display()))?;
275 fs::write(path, out).map_err(|e| format!("write {}: {e}", path.display()))?;
276 Ok(true)
277}
278
279pub fn run_package_json_script(dir: &Path, script: &str) -> Result<(), String> {
282 let pm = detect_js_install_package_manager(dir);
284 if pm == JsPackageManager::Pnpm {
285 let _ = ensure_pnpm_workspace_packages(dir);
286 }
287
288 match run_package_json_script_once(dir, pm, script) {
289 Ok(()) => Ok(()),
290 Err(err) if is_pnpm_packages_field_error(&err) => {
291 let _ = ensure_pnpm_workspace_packages(dir);
292 let fallback = if dir.join("bun.lockb").is_file() || dir.join("bun.lock").is_file() {
293 JsPackageManager::Bun
294 } else if dir.join("package-lock.json").is_file() {
295 JsPackageManager::Npm
296 } else {
297 pm
298 };
299 if fallback != pm {
300 eprintln!(
301 "xbp: pnpm workspace packages error — retrying with {} run {script}",
302 fallback.name()
303 );
304 return run_package_json_script_once(dir, fallback, script);
305 }
306 eprintln!("xbp: fixed pnpm-workspace packages field — retrying pnpm run {script}");
307 run_package_json_script_once(dir, JsPackageManager::Pnpm, script)
308 }
309 Err(err) => Err(err),
310 }
311}
312
313fn run_package_json_script_once(
314 dir: &Path,
315 pm: JsPackageManager,
316 script: &str,
317) -> Result<(), String> {
318 let bin = pm.bin();
319 let args = pm.run_script_args(script);
320 let output = Command::new(bin)
321 .args(&args)
322 .current_dir(dir)
323 .stdout(Stdio::inherit())
324 .stderr(Stdio::piped())
325 .output()
326 .map_err(|e| format!("Failed to run `{} {}`: {e}", bin, args.join(" ")))?;
327
328 if !output.stderr.is_empty() {
329 let _ = std::io::Write::write_all(&mut std::io::stderr(), &output.stderr);
330 }
331
332 if output.status.success() {
333 return Ok(());
334 }
335 let stderr = String::from_utf8_lossy(&output.stderr);
336 Err(format!(
337 "`{} {}` failed in {} with status {}.\n{}",
338 bin,
339 args.join(" "),
340 dir.display(),
341 output.status,
342 stderr.trim()
343 ))
344}
345
346pub fn is_pnpm_packages_field_error(text: &str) -> bool {
347 let lower = text.to_ascii_lowercase();
348 lower.contains("packages field missing or empty")
349 || (lower.contains("packages field") && lower.contains("empty"))
350}
351
352pub fn is_package_manager_mismatch_error(text: &str) -> bool {
354 let lower = text.to_ascii_lowercase();
355 let install_bun = lower.contains("bun install") || lower.contains("installing project dependencies: bun");
356 let install_npm = lower.contains("npm install") || lower.contains("npm ci");
357 let build_pnpm = lower.contains("pnpm run ") || lower.contains("executing user build command: pnpm");
358 let build_yarn = lower.contains("yarn run ") || lower.contains("executing user build command: yarn");
359 (install_bun || install_npm) && (build_pnpm || build_yarn || is_pnpm_packages_field_error(text))
360}
361
362pub fn suggested_build_command(dir: &Path, script: &str) -> String {
365 let pm = detect_js_install_package_manager(dir);
366 match pm {
367 JsPackageManager::Bun => format!("bun run {script}"),
368 JsPackageManager::Pnpm => format!("pnpm run {script}"),
369 JsPackageManager::Yarn => format!("yarn run {script}"),
370 JsPackageManager::Npm => format!("npm run {script}"),
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Default)]
376pub struct BuildsFailureClassification {
377 pub packages_field_missing: bool,
378 pub package_manager_mismatch: bool,
379 pub notes: Vec<String>,
380}
381
382impl BuildsFailureClassification {
383 pub fn is_known(&self) -> bool {
384 self.packages_field_missing || self.package_manager_mismatch
385 }
386
387 pub fn needs_workspace_heal(&self) -> bool {
388 self.packages_field_missing
389 }
390
391 pub fn needs_build_command_sync(&self) -> bool {
392 self.packages_field_missing || self.package_manager_mismatch
393 }
394}
395
396pub fn classify_builds_failure(log_text: &str) -> BuildsFailureClassification {
397 let mut c = BuildsFailureClassification::default();
398 if is_pnpm_packages_field_error(log_text) {
399 c.packages_field_missing = true;
400 c.notes
401 .push("pnpm-workspace.yaml missing or empty packages field".into());
402 }
403 if is_package_manager_mismatch_error(log_text) {
404 c.package_manager_mismatch = true;
405 c.notes.push(
406 "install package manager differs from build command (e.g. bun install + pnpm run)"
407 .into(),
408 );
409 }
410 c
411}
412
413pub fn flatten_build_logs_json(value: &Value) -> String {
415 let mut out = String::new();
416 flatten_build_logs_value(value, &mut out);
417 out
418}
419
420fn flatten_build_logs_value(value: &Value, out: &mut String) {
421 match value {
422 Value::String(s) => {
423 if !out.is_empty() {
424 out.push('\n');
425 }
426 out.push_str(s);
427 }
428 Value::Array(items) => {
429 for item in items {
430 flatten_build_logs_value(item, out);
431 }
432 }
433 Value::Object(map) => {
434 for key in ["message", "text", "line", "log", "content", "body", "data"] {
435 if let Some(v) = map.get(key) {
436 flatten_build_logs_value(v, out);
437 }
438 }
439 for (k, v) in map {
441 if matches!(
442 k.as_str(),
443 "message" | "text" | "line" | "log" | "content" | "body" | "data"
444 ) {
445 continue;
446 }
447 if v.is_string() || v.is_array() || v.is_object() {
448 flatten_build_logs_value(v, out);
449 }
450 }
451 }
452 Value::Number(n) => {
453 if !out.is_empty() {
454 out.push('\n');
455 }
456 out.push_str(&n.to_string());
457 }
458 Value::Bool(b) => {
459 if !out.is_empty() {
460 out.push('\n');
461 }
462 out.push_str(if *b { "true" } else { "false" });
463 }
464 Value::Null => {}
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use std::fs;
472
473 fn temp_dir(name: &str) -> PathBuf {
474 let p = std::env::temp_dir().join(format!(
475 "xbp-jspm-{}-{}",
476 name,
477 std::time::SystemTime::now()
478 .duration_since(std::time::UNIX_EPOCH)
479 .unwrap()
480 .as_nanos()
481 ));
482 let _ = fs::create_dir_all(&p);
483 p
484 }
485
486 #[test]
487 fn detects_bun_from_lock() {
488 let d = temp_dir("bun");
489 fs::write(d.join("bun.lock"), "").unwrap();
490 assert_eq!(detect_js_package_manager(&d), JsPackageManager::Bun);
491 let _ = fs::remove_dir_all(d);
492 }
493
494 #[test]
495 fn detects_package_manager_field() {
496 let d = temp_dir("pmfield");
497 fs::write(
498 d.join("package.json"),
499 r#"{"packageManager":"bun@1.2.0"}"#,
500 )
501 .unwrap();
502 assert_eq!(detect_js_package_manager(&d), JsPackageManager::Bun);
503 let _ = fs::remove_dir_all(d);
504 }
505
506 #[test]
507 fn install_pm_prefers_bun_lock_over_package_manager_pnpm() {
508 let d = temp_dir("dual");
509 fs::write(
510 d.join("package.json"),
511 r#"{"packageManager":"pnpm@10.0.0"}"#,
512 )
513 .unwrap();
514 fs::write(d.join("bun.lock"), "").unwrap();
515 fs::write(d.join("pnpm-lock.yaml"), "").unwrap();
516 assert_eq!(detect_js_package_manager(&d), JsPackageManager::Pnpm);
518 assert_eq!(
520 detect_js_install_package_manager(&d),
521 JsPackageManager::Bun
522 );
523 assert_eq!(suggested_build_command(&d, "build"), "bun run build");
524 let _ = fs::remove_dir_all(d);
525 }
526
527 #[test]
528 fn heals_empty_packages_field_single_package() {
529 let d = temp_dir("ws");
530 fs::write(d.join("pnpm-workspace.yaml"), "allowBuilds: {}\n").unwrap();
531 let fixed = ensure_pnpm_workspace_packages(&d).unwrap();
532 assert_eq!(fixed.len(), 1);
533 let raw = fs::read_to_string(d.join("pnpm-workspace.yaml")).unwrap();
534 assert!(raw.contains("packages:"), "{raw}");
535 assert!(raw.contains("."), "{raw}");
536 assert!(
538 !raw.contains("apps/*") || raw.contains("packages:"),
539 "{raw}"
540 );
541 assert!(!raw.contains("apps/*"), "{raw}");
543 assert!(!raw.contains("packages/*"), "{raw}");
544 let _ = fs::remove_dir_all(d);
545 }
546
547 #[test]
548 fn heals_with_monorepo_globs_when_nested_packages_exist() {
549 let d = temp_dir("mono");
550 fs::create_dir_all(d.join("packages/foo")).unwrap();
551 fs::write(d.join("packages/foo/package.json"), r#"{}"#).unwrap();
552 fs::write(d.join("pnpm-workspace.yaml"), "allowBuilds: {}\n").unwrap();
553 let fixed = ensure_pnpm_workspace_packages(&d).unwrap();
554 assert_eq!(fixed.len(), 1);
555 let raw = fs::read_to_string(d.join("pnpm-workspace.yaml")).unwrap();
556 assert!(raw.contains("packages/*"), "{raw}");
557 let _ = fs::remove_dir_all(d);
558 }
559
560 #[test]
561 fn packages_error_detection() {
562 assert!(is_pnpm_packages_field_error(
563 "ERROR packages field missing or empty\nFor help, run: pnpm help run"
564 ));
565 }
566
567 #[test]
568 fn suggested_build_prefers_bun() {
569 let d = temp_dir("sug");
570 fs::write(d.join("bun.lockb"), "").unwrap();
571 assert_eq!(suggested_build_command(&d, "build"), "bun run build");
572 let _ = fs::remove_dir_all(d);
573 }
574
575 #[test]
576 fn classifies_user_failure_log() {
577 let log = r#"
578Installing project dependencies: bun install --frozen-lockfile
579...
580Executing user build command: pnpm run build
581ERROR packages field missing or empty
582"#;
583 let c = classify_builds_failure(log);
584 assert!(c.packages_field_missing);
585 assert!(c.package_manager_mismatch);
586 assert!(c.is_known());
587 assert!(c.needs_workspace_heal());
588 assert!(c.needs_build_command_sync());
589 }
590
591 #[test]
592 fn flatten_logs_array_of_strings() {
593 let v = serde_json::json!(["line1", "packages field missing or empty"]);
594 let flat = flatten_build_logs_json(&v);
595 assert!(flat.contains("packages field missing or empty"));
596 }
597
598 #[test]
599 fn flatten_logs_nested_message() {
600 let v = serde_json::json!({
601 "logs": [
602 {"message": "bun install"},
603 {"text": "pnpm run build"}
604 ]
605 });
606 let flat = flatten_build_logs_json(&v);
607 assert!(flat.contains("bun install"));
608 assert!(flat.contains("pnpm run build"));
609 }
610}