use anyhow::{Context, Result, bail};
use serde_json::Value;
use std::path::Path;
use tokio::process::Command;
const PACKAGE_JSON: &str = "package.json";
const LOCK_FILE: &str = "bun.lock";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum BunDependencyMode {
#[default]
Disabled,
Locked,
}
impl BunDependencyMode {
pub fn as_manifest_value(self) -> Option<&'static str> {
match self {
Self::Disabled => None,
Self::Locked => Some("locked"),
}
}
pub fn install_arg(self) -> &'static str {
match self {
Self::Disabled => "--no-install",
Self::Locked => "--install=fallback",
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BunRuntimeSpec {
pub dependency_mode: BunDependencyMode,
pub dependency_hash: Option<u64>,
}
pub fn resolve(category_root: &Path, allow_external_dependencies: bool) -> Result<BunRuntimeSpec> {
if !allow_external_dependencies {
return Ok(BunRuntimeSpec::default());
}
let package_path = category_root.join(PACKAGE_JSON);
let lock_path = category_root.join(LOCK_FILE);
let has_package = package_path.is_file();
let has_lock = lock_path.is_file();
match (has_package, has_lock) {
(false, false) => return Ok(BunRuntimeSpec::default()),
(true, false) => bail!(
"external Bun preset dependency declaration requires {} beside {}",
lock_path.display(),
package_path.display()
),
(false, true) => bail!(
"external Bun preset dependency lock requires {} beside {}",
package_path.display(),
lock_path.display()
),
(true, true) => {}
}
let package = std::fs::read(&package_path)
.with_context(|| format!("reading Bun preset package: {}", package_path.display()))?;
let parsed: Value = serde_json::from_slice(&package)
.with_context(|| format!("parsing Bun preset package: {}", package_path.display()))?;
if parsed.get("trustedDependencies").is_some() {
bail!(
"external Bun preset package must not declare trustedDependencies: {}",
package_path.display()
);
}
let lock = std::fs::read(&lock_path)
.with_context(|| format!("reading Bun preset lock: {}", lock_path.display()))?;
let mut bytes = Vec::with_capacity(package.len() + lock.len() + 1);
bytes.extend_from_slice(&package);
bytes.push(0);
bytes.extend_from_slice(&lock);
Ok(BunRuntimeSpec {
dependency_mode: BunDependencyMode::Locked,
dependency_hash: Some(crate::install_core::hash_content(&bytes)),
})
}
pub fn command(script: &Path, spec: BunRuntimeSpec) -> Command {
let mut command = Command::new("bun");
command.arg(spec.dependency_mode.install_arg()).arg(script);
command
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
async fn temp_dir(name: &str) -> PathBuf {
crate::test_support::make_temp_dir(name).await
}
#[tokio::test]
async fn built_in_scripts_ignore_package_files() {
let dir = temp_dir("bun-runtime-built-in").await;
std::fs::write(
dir.join(PACKAGE_JSON),
r#"{"dependencies":{"zod":"4.0.0"}}"#,
)
.unwrap();
let spec = resolve(&dir, false).unwrap();
assert_eq!(spec, BunRuntimeSpec::default());
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn external_scripts_require_a_complete_locked_pair() {
let dir = temp_dir("bun-runtime-pair").await;
std::fs::write(
dir.join(PACKAGE_JSON),
r#"{"dependencies":{"zod":"4.0.0"}}"#,
)
.unwrap();
let error = resolve(&dir, true).unwrap_err().to_string();
assert!(error.contains("bun.lock"));
std::fs::remove_file(dir.join(PACKAGE_JSON)).unwrap();
std::fs::write(dir.join(LOCK_FILE), "{}").unwrap();
let error = resolve(&dir, true).unwrap_err().to_string();
assert!(error.contains("package.json"));
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn external_scripts_accept_locked_dependencies_and_hash_both_files() {
let dir = temp_dir("bun-runtime-locked").await;
std::fs::write(
dir.join(PACKAGE_JSON),
r#"{"dependencies":{"zod":"4.0.0"}}"#,
)
.unwrap();
std::fs::write(dir.join(LOCK_FILE), "lockfileVersion = 1\n").unwrap();
let first = resolve(&dir, true).unwrap();
assert_eq!(first.dependency_mode, BunDependencyMode::Locked);
assert!(first.dependency_hash.is_some());
std::fs::write(dir.join(LOCK_FILE), "lockfileVersion = 1\n# changed\n").unwrap();
let second = resolve(&dir, true).unwrap();
assert_ne!(first.dependency_hash, second.dependency_hash);
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn external_scripts_reject_trusted_dependencies() {
let dir = temp_dir("bun-runtime-trusted").await;
std::fs::write(dir.join(PACKAGE_JSON), r#"{"trustedDependencies":[]}"#).unwrap();
std::fs::write(dir.join(LOCK_FILE), "{}").unwrap();
let error = resolve(&dir, true).unwrap_err().to_string();
assert!(error.contains("trustedDependencies"));
std::fs::remove_dir_all(dir).unwrap();
}
}