use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsPackageManager {
Bun,
Pnpm,
Yarn,
Npm,
}
impl JsPackageManager {
pub fn name(self) -> &'static str {
match self {
Self::Bun => "bun",
Self::Pnpm => "pnpm",
Self::Yarn => "yarn",
Self::Npm => "npm",
}
}
pub fn bin(self) -> &'static str {
match self {
Self::Bun => {
if cfg!(windows) {
"bun.exe"
} else {
"bun"
}
}
Self::Pnpm => {
if cfg!(windows) {
"pnpm.cmd"
} else {
"pnpm"
}
}
Self::Yarn => {
if cfg!(windows) {
"yarn.cmd"
} else {
"yarn"
}
}
Self::Npm => {
if cfg!(windows) {
"npm.cmd"
} else {
"npm"
}
}
}
}
pub fn run_script_args(self, script: &str) -> Vec<String> {
match self {
Self::Yarn => vec!["run".into(), script.into()],
Self::Bun => vec!["run".into(), script.into()],
Self::Pnpm | Self::Npm => vec!["run".into(), script.into()],
}
}
}
pub fn detect_js_package_manager(dir: &Path) -> JsPackageManager {
if let Some(pm) = package_manager_from_package_json(&dir.join("package.json")) {
return pm;
}
let mut cur = dir.to_path_buf();
for _ in 0..4 {
if let Some(pm) = package_manager_from_package_json(&cur.join("package.json")) {
return pm;
}
if let Some(pm) = package_manager_from_lockfiles(&cur) {
return pm;
}
if !cur.pop() {
break;
}
}
package_manager_from_lockfiles(dir).unwrap_or(JsPackageManager::Npm)
}
pub fn detect_js_install_package_manager(dir: &Path) -> JsPackageManager {
let mut cur = dir.to_path_buf();
for _ in 0..4 {
if let Some(pm) = package_manager_from_lockfiles_strict(&cur) {
return pm;
}
if let Some(pm) = package_manager_from_package_json(&cur.join("package.json")) {
return pm;
}
if cur.join("pnpm-workspace.yaml").is_file() || cur.join("pnpm-workspace.yml").is_file() {
return JsPackageManager::Pnpm;
}
if !cur.pop() {
break;
}
}
package_manager_from_lockfiles_strict(dir)
.or_else(|| package_manager_from_package_json(&dir.join("package.json")))
.unwrap_or(JsPackageManager::Npm)
}
fn package_manager_from_lockfiles_strict(dir: &Path) -> Option<JsPackageManager> {
if dir.join("bun.lockb").is_file() || dir.join("bun.lock").is_file() {
return Some(JsPackageManager::Bun);
}
if dir.join("pnpm-lock.yaml").is_file() {
return Some(JsPackageManager::Pnpm);
}
if dir.join("yarn.lock").is_file() {
return Some(JsPackageManager::Yarn);
}
if dir.join("package-lock.json").is_file() || dir.join("npm-shrinkwrap.json").is_file() {
return Some(JsPackageManager::Npm);
}
None
}
fn package_manager_from_lockfiles(dir: &Path) -> Option<JsPackageManager> {
if let Some(pm) = package_manager_from_lockfiles_strict(dir) {
return Some(pm);
}
if dir.join("pnpm-workspace.yaml").is_file() || dir.join("pnpm-workspace.yml").is_file() {
return Some(JsPackageManager::Pnpm);
}
None
}
fn package_manager_from_package_json(path: &Path) -> Option<JsPackageManager> {
let content = fs::read_to_string(path).ok()?;
let json: Value = serde_json::from_str(&content).ok()?;
let package_manager = json.get("packageManager")?.as_str()?;
let name = package_manager
.split('@')
.next()?
.trim()
.to_ascii_lowercase();
match name.as_str() {
"bun" => Some(JsPackageManager::Bun),
"pnpm" => Some(JsPackageManager::Pnpm),
"yarn" => Some(JsPackageManager::Yarn),
"npm" => Some(JsPackageManager::Npm),
_ => None,
}
}
pub fn ensure_pnpm_workspace_packages(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut fixed = Vec::new();
let mut cur = dir.to_path_buf();
loop {
for name in ["pnpm-workspace.yaml", "pnpm-workspace.yml"] {
let path = cur.join(name);
if path.is_file() {
if fix_pnpm_workspace_packages_file(&path)? {
fixed.push(path);
}
}
}
if !cur.pop() {
break;
}
}
Ok(fixed)
}
fn dir_has_nested_package_json(root: &Path, child: &str) -> bool {
let base = root.join(child);
if !base.is_dir() {
return false;
}
let Ok(entries) = fs::read_dir(&base) else {
return false;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() && p.join("package.json").is_file() {
return true;
}
}
false
}
fn default_packages_for_workspace_root(workspace_dir: &Path) -> Vec<String> {
let has_apps = dir_has_nested_package_json(workspace_dir, "apps");
let has_packages = dir_has_nested_package_json(workspace_dir, "packages");
if has_apps || has_packages {
let mut pkgs = vec![".".to_string()];
if has_apps {
pkgs.push("apps/*".to_string());
}
if has_packages {
pkgs.push("packages/*".to_string());
}
pkgs
} else {
vec![".".to_string()]
}
}
fn fix_pnpm_workspace_packages_file(path: &Path) -> Result<bool, String> {
let raw = fs::read_to_string(path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
let mut value: serde_yaml::Value = if raw.trim().is_empty() {
serde_yaml::Value::Mapping(serde_yaml::Mapping::new())
} else {
serde_yaml::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?
};
let packages_ok = value
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("packages".into())))
.and_then(|p| p.as_sequence())
.map(|s| !s.is_empty())
.unwrap_or(false);
if packages_ok {
return Ok(false);
}
let map = value
.as_mapping_mut()
.ok_or_else(|| format!("{} is not a YAML mapping", path.display()))?;
let workspace_dir = path.parent().unwrap_or_else(|| Path::new("."));
let default_list = default_packages_for_workspace_root(workspace_dir);
let default_packages = serde_yaml::Value::Sequence(
default_list
.into_iter()
.map(serde_yaml::Value::String)
.collect(),
);
map.insert(
serde_yaml::Value::String("packages".into()),
default_packages,
);
let out = serde_yaml::to_string(&value)
.map_err(|e| format!("serialize {}: {e}", path.display()))?;
fs::write(path, out).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(true)
}
pub fn run_package_json_script(dir: &Path, script: &str) -> Result<(), String> {
let pm = detect_js_install_package_manager(dir);
if pm == JsPackageManager::Pnpm {
let _ = ensure_pnpm_workspace_packages(dir);
}
match run_package_json_script_once(dir, pm, script) {
Ok(()) => Ok(()),
Err(err) if is_pnpm_packages_field_error(&err) => {
let _ = ensure_pnpm_workspace_packages(dir);
let fallback = if dir.join("bun.lockb").is_file() || dir.join("bun.lock").is_file() {
JsPackageManager::Bun
} else if dir.join("package-lock.json").is_file() {
JsPackageManager::Npm
} else {
pm
};
if fallback != pm {
eprintln!(
"xbp: pnpm workspace packages error — retrying with {} run {script}",
fallback.name()
);
return run_package_json_script_once(dir, fallback, script);
}
eprintln!("xbp: fixed pnpm-workspace packages field — retrying pnpm run {script}");
run_package_json_script_once(dir, JsPackageManager::Pnpm, script)
}
Err(err) => Err(err),
}
}
fn run_package_json_script_once(
dir: &Path,
pm: JsPackageManager,
script: &str,
) -> Result<(), String> {
let bin = pm.bin();
let args = pm.run_script_args(script);
let output = Command::new(bin)
.args(&args)
.current_dir(dir)
.stdout(Stdio::inherit())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("Failed to run `{} {}`: {e}", bin, args.join(" ")))?;
if !output.stderr.is_empty() {
let _ = std::io::Write::write_all(&mut std::io::stderr(), &output.stderr);
}
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"`{} {}` failed in {} with status {}.\n{}",
bin,
args.join(" "),
dir.display(),
output.status,
stderr.trim()
))
}
pub fn is_pnpm_packages_field_error(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("packages field missing or empty")
|| (lower.contains("packages field") && lower.contains("empty"))
}
pub fn is_package_manager_mismatch_error(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
let install_bun = lower.contains("bun install") || lower.contains("installing project dependencies: bun");
let install_npm = lower.contains("npm install") || lower.contains("npm ci");
let build_pnpm = lower.contains("pnpm run ") || lower.contains("executing user build command: pnpm");
let build_yarn = lower.contains("yarn run ") || lower.contains("executing user build command: yarn");
(install_bun || install_npm) && (build_pnpm || build_yarn || is_pnpm_packages_field_error(text))
}
pub fn suggested_build_command(dir: &Path, script: &str) -> String {
let pm = detect_js_install_package_manager(dir);
match pm {
JsPackageManager::Bun => format!("bun run {script}"),
JsPackageManager::Pnpm => format!("pnpm run {script}"),
JsPackageManager::Yarn => format!("yarn run {script}"),
JsPackageManager::Npm => format!("npm run {script}"),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct BuildsFailureClassification {
pub packages_field_missing: bool,
pub package_manager_mismatch: bool,
pub notes: Vec<String>,
}
impl BuildsFailureClassification {
pub fn is_known(&self) -> bool {
self.packages_field_missing || self.package_manager_mismatch
}
pub fn needs_workspace_heal(&self) -> bool {
self.packages_field_missing
}
pub fn needs_build_command_sync(&self) -> bool {
self.packages_field_missing || self.package_manager_mismatch
}
}
pub fn classify_builds_failure(log_text: &str) -> BuildsFailureClassification {
let mut c = BuildsFailureClassification::default();
if is_pnpm_packages_field_error(log_text) {
c.packages_field_missing = true;
c.notes
.push("pnpm-workspace.yaml missing or empty packages field".into());
}
if is_package_manager_mismatch_error(log_text) {
c.package_manager_mismatch = true;
c.notes.push(
"install package manager differs from build command (e.g. bun install + pnpm run)"
.into(),
);
}
c
}
pub fn flatten_build_logs_json(value: &Value) -> String {
let mut out = String::new();
flatten_build_logs_value(value, &mut out);
out
}
fn flatten_build_logs_value(value: &Value, out: &mut String) {
match value {
Value::String(s) => {
if !out.is_empty() {
out.push('\n');
}
out.push_str(s);
}
Value::Array(items) => {
for item in items {
flatten_build_logs_value(item, out);
}
}
Value::Object(map) => {
for key in ["message", "text", "line", "log", "content", "body", "data"] {
if let Some(v) = map.get(key) {
flatten_build_logs_value(v, out);
}
}
for (k, v) in map {
if matches!(
k.as_str(),
"message" | "text" | "line" | "log" | "content" | "body" | "data"
) {
continue;
}
if v.is_string() || v.is_array() || v.is_object() {
flatten_build_logs_value(v, out);
}
}
}
Value::Number(n) => {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&n.to_string());
}
Value::Bool(b) => {
if !out.is_empty() {
out.push('\n');
}
out.push_str(if *b { "true" } else { "false" });
}
Value::Null => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"xbp-jspm-{}-{}",
name,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::create_dir_all(&p);
p
}
#[test]
fn detects_bun_from_lock() {
let d = temp_dir("bun");
fs::write(d.join("bun.lock"), "").unwrap();
assert_eq!(detect_js_package_manager(&d), JsPackageManager::Bun);
let _ = fs::remove_dir_all(d);
}
#[test]
fn detects_package_manager_field() {
let d = temp_dir("pmfield");
fs::write(
d.join("package.json"),
r#"{"packageManager":"bun@1.2.0"}"#,
)
.unwrap();
assert_eq!(detect_js_package_manager(&d), JsPackageManager::Bun);
let _ = fs::remove_dir_all(d);
}
#[test]
fn install_pm_prefers_bun_lock_over_package_manager_pnpm() {
let d = temp_dir("dual");
fs::write(
d.join("package.json"),
r#"{"packageManager":"pnpm@10.0.0"}"#,
)
.unwrap();
fs::write(d.join("bun.lock"), "").unwrap();
fs::write(d.join("pnpm-lock.yaml"), "").unwrap();
assert_eq!(detect_js_package_manager(&d), JsPackageManager::Pnpm);
assert_eq!(
detect_js_install_package_manager(&d),
JsPackageManager::Bun
);
assert_eq!(suggested_build_command(&d, "build"), "bun run build");
let _ = fs::remove_dir_all(d);
}
#[test]
fn heals_empty_packages_field_single_package() {
let d = temp_dir("ws");
fs::write(d.join("pnpm-workspace.yaml"), "allowBuilds: {}\n").unwrap();
let fixed = ensure_pnpm_workspace_packages(&d).unwrap();
assert_eq!(fixed.len(), 1);
let raw = fs::read_to_string(d.join("pnpm-workspace.yaml")).unwrap();
assert!(raw.contains("packages:"), "{raw}");
assert!(raw.contains("."), "{raw}");
assert!(
!raw.contains("apps/*") || raw.contains("packages:"),
"{raw}"
);
assert!(!raw.contains("apps/*"), "{raw}");
assert!(!raw.contains("packages/*"), "{raw}");
let _ = fs::remove_dir_all(d);
}
#[test]
fn heals_with_monorepo_globs_when_nested_packages_exist() {
let d = temp_dir("mono");
fs::create_dir_all(d.join("packages/foo")).unwrap();
fs::write(d.join("packages/foo/package.json"), r#"{}"#).unwrap();
fs::write(d.join("pnpm-workspace.yaml"), "allowBuilds: {}\n").unwrap();
let fixed = ensure_pnpm_workspace_packages(&d).unwrap();
assert_eq!(fixed.len(), 1);
let raw = fs::read_to_string(d.join("pnpm-workspace.yaml")).unwrap();
assert!(raw.contains("packages/*"), "{raw}");
let _ = fs::remove_dir_all(d);
}
#[test]
fn packages_error_detection() {
assert!(is_pnpm_packages_field_error(
"ERROR packages field missing or empty\nFor help, run: pnpm help run"
));
}
#[test]
fn suggested_build_prefers_bun() {
let d = temp_dir("sug");
fs::write(d.join("bun.lockb"), "").unwrap();
assert_eq!(suggested_build_command(&d, "build"), "bun run build");
let _ = fs::remove_dir_all(d);
}
#[test]
fn classifies_user_failure_log() {
let log = r#"
Installing project dependencies: bun install --frozen-lockfile
...
Executing user build command: pnpm run build
ERROR packages field missing or empty
"#;
let c = classify_builds_failure(log);
assert!(c.packages_field_missing);
assert!(c.package_manager_mismatch);
assert!(c.is_known());
assert!(c.needs_workspace_heal());
assert!(c.needs_build_command_sync());
}
#[test]
fn flatten_logs_array_of_strings() {
let v = serde_json::json!(["line1", "packages field missing or empty"]);
let flat = flatten_build_logs_json(&v);
assert!(flat.contains("packages field missing or empty"));
}
#[test]
fn flatten_logs_nested_message() {
let v = serde_json::json!({
"logs": [
{"message": "bun install"},
{"text": "pnpm run build"}
]
});
let flat = flatten_build_logs_json(&v);
assert!(flat.contains("bun install"));
assert!(flat.contains("pnpm run build"));
}
}