Skip to main content

xbp_cli/utils/
js_package_manager.rs

1//! Detect and invoke the right JS package manager for `run` scripts.
2//!
3//! Cloudflare CI often installs with **bun** (bun.lock / package-lock) while
4//! build commands still say `pnpm run build`. That, plus a `pnpm-workspace.yaml`
5//! with a missing/empty `packages:` field, yields:
6//!   ERROR  packages field missing or empty
7
8use 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    /// Binary for spawning (`pnpm.cmd` on Windows, etc.).
32    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    /// Args for running a package.json script by name.
66    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
75/// Detect package manager for **local** `run` scripts.
76///
77/// Order:
78/// 1. `package.json#packageManager` (walk up a few levels)
79/// 2. lockfiles: bun → pnpm → yarn → npm
80/// 3. `pnpm-workspace.yaml` alone → pnpm
81/// 4. default npm
82pub 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
101/// Detect package manager for **CI install** (Workers Builds).
102///
103/// Lockfiles win over `packageManager` so dual-lock trees where CF installs with
104/// bun still get `bun run build` even if package.json says `pnpm@…`.
105///
106/// Order (walk up a few levels):
107/// 1. bun.lock / bun.lockb
108/// 2. pnpm-lock.yaml
109/// 3. yarn.lock
110/// 4. package-lock.json / npm-shrinkwrap.json
111/// 5. packageManager field
112/// 6. pnpm-workspace.yaml alone → pnpm
113/// 7. default npm
114pub 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
135/// Lockfiles only (bun first) — no packageManager, no workspace yaml.
136fn 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
180/// Ensure any `pnpm-workspace.yaml` above `dir` has a non-empty `packages` list.
181/// Returns paths that were rewritten.
182pub 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        // Single-package project (policy-only yaml under apps/web, etc.)
232        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
279/// Run `package.json` script with the detected package manager.
280/// Auto-heals empty pnpm workspace `packages` and retries once on that error.
281pub fn run_package_json_script(dir: &Path, script: &str) -> Result<(), String> {
282    // Prefer install-aware PM when lockfiles disagree with packageManager.
283    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
352/// True when log text shows CF installed with one PM and built with another.
353pub 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
362/// Suggested Cloudflare / CI build command for this package tree.
363/// Uses **install-aware** detection so dual-lock apps match CF install step.
364pub 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/// Classify known Workers Builds failure patterns from log text.
375#[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
413/// Flatten Workers Builds log JSON into searchable plain text.
414pub 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            // Fallback: any nested strings
440            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        // Local detect still honors packageManager
517        assert_eq!(detect_js_package_manager(&d), JsPackageManager::Pnpm);
518        // CI / Builds use install lock
519        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        // No apps/* when no nested packages
537        assert!(
538            !raw.contains("apps/*") || raw.contains("packages:"),
539            "{raw}"
540        );
541        // Single-package default should be just "."
542        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}