1use anyhow::{Context, Result, bail};
2use serde_json::Value;
3use std::path::Path;
4use tokio::process::Command;
5
6const PACKAGE_JSON: &str = "package.json";
7const LOCK_FILE: &str = "bun.lock";
8
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10pub enum BunDependencyMode {
11 #[default]
12 Disabled,
13 Locked,
14}
15
16impl BunDependencyMode {
17 pub fn as_manifest_value(self) -> Option<&'static str> {
18 match self {
19 Self::Disabled => None,
20 Self::Locked => Some("locked"),
21 }
22 }
23
24 pub fn install_arg(self) -> &'static str {
25 match self {
26 Self::Disabled => "--no-install",
27 Self::Locked => "--install=fallback",
28 }
29 }
30}
31
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub struct BunRuntimeSpec {
34 pub dependency_mode: BunDependencyMode,
35 pub dependency_hash: Option<u64>,
36}
37
38pub fn resolve(category_root: &Path, allow_external_dependencies: bool) -> Result<BunRuntimeSpec> {
44 if !allow_external_dependencies {
45 return Ok(BunRuntimeSpec::default());
46 }
47
48 let package_path = category_root.join(PACKAGE_JSON);
49 let lock_path = category_root.join(LOCK_FILE);
50 let has_package = package_path.is_file();
51 let has_lock = lock_path.is_file();
52 match (has_package, has_lock) {
53 (false, false) => return Ok(BunRuntimeSpec::default()),
54 (true, false) => bail!(
55 "external Bun preset dependency declaration requires {} beside {}",
56 lock_path.display(),
57 package_path.display()
58 ),
59 (false, true) => bail!(
60 "external Bun preset dependency lock requires {} beside {}",
61 package_path.display(),
62 lock_path.display()
63 ),
64 (true, true) => {}
65 }
66
67 let package = std::fs::read(&package_path)
68 .with_context(|| format!("reading Bun preset package: {}", package_path.display()))?;
69 let parsed: Value = serde_json::from_slice(&package)
70 .with_context(|| format!("parsing Bun preset package: {}", package_path.display()))?;
71 if parsed.get("trustedDependencies").is_some() {
72 bail!(
73 "external Bun preset package must not declare trustedDependencies: {}",
74 package_path.display()
75 );
76 }
77
78 let lock = std::fs::read(&lock_path)
79 .with_context(|| format!("reading Bun preset lock: {}", lock_path.display()))?;
80 let mut bytes = Vec::with_capacity(package.len() + lock.len() + 1);
81 bytes.extend_from_slice(&package);
82 bytes.push(0);
83 bytes.extend_from_slice(&lock);
84 Ok(BunRuntimeSpec {
85 dependency_mode: BunDependencyMode::Locked,
86 dependency_hash: Some(crate::install_core::hash_content(&bytes)),
87 })
88}
89
90pub fn command(script: &Path, spec: BunRuntimeSpec) -> Command {
91 let mut command = Command::new("bun");
92 command.arg(spec.dependency_mode.install_arg()).arg(script);
93 command
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use std::path::PathBuf;
100
101 async fn temp_dir(name: &str) -> PathBuf {
102 crate::test_support::make_temp_dir(name).await
103 }
104
105 #[tokio::test]
106 async fn built_in_scripts_ignore_package_files() {
107 let dir = temp_dir("bun-runtime-built-in").await;
108 std::fs::write(
109 dir.join(PACKAGE_JSON),
110 r#"{"dependencies":{"zod":"4.0.0"}}"#,
111 )
112 .unwrap();
113 let spec = resolve(&dir, false).unwrap();
114 assert_eq!(spec, BunRuntimeSpec::default());
115 std::fs::remove_dir_all(dir).unwrap();
116 }
117
118 #[tokio::test]
119 async fn external_scripts_require_a_complete_locked_pair() {
120 let dir = temp_dir("bun-runtime-pair").await;
121 std::fs::write(
122 dir.join(PACKAGE_JSON),
123 r#"{"dependencies":{"zod":"4.0.0"}}"#,
124 )
125 .unwrap();
126 let error = resolve(&dir, true).unwrap_err().to_string();
127 assert!(error.contains("bun.lock"));
128 std::fs::remove_file(dir.join(PACKAGE_JSON)).unwrap();
129 std::fs::write(dir.join(LOCK_FILE), "{}").unwrap();
130 let error = resolve(&dir, true).unwrap_err().to_string();
131 assert!(error.contains("package.json"));
132 std::fs::remove_dir_all(dir).unwrap();
133 }
134
135 #[tokio::test]
136 async fn external_scripts_accept_locked_dependencies_and_hash_both_files() {
137 let dir = temp_dir("bun-runtime-locked").await;
138 std::fs::write(
139 dir.join(PACKAGE_JSON),
140 r#"{"dependencies":{"zod":"4.0.0"}}"#,
141 )
142 .unwrap();
143 std::fs::write(dir.join(LOCK_FILE), "lockfileVersion = 1\n").unwrap();
144 let first = resolve(&dir, true).unwrap();
145 assert_eq!(first.dependency_mode, BunDependencyMode::Locked);
146 assert!(first.dependency_hash.is_some());
147 std::fs::write(dir.join(LOCK_FILE), "lockfileVersion = 1\n# changed\n").unwrap();
148 let second = resolve(&dir, true).unwrap();
149 assert_ne!(first.dependency_hash, second.dependency_hash);
150 std::fs::remove_dir_all(dir).unwrap();
151 }
152
153 #[tokio::test]
154 async fn external_scripts_reject_trusted_dependencies() {
155 let dir = temp_dir("bun-runtime-trusted").await;
156 std::fs::write(dir.join(PACKAGE_JSON), r#"{"trustedDependencies":[]}"#).unwrap();
157 std::fs::write(dir.join(LOCK_FILE), "{}").unwrap();
158 let error = resolve(&dir, true).unwrap_err().to_string();
159 assert!(error.contains("trustedDependencies"));
160 std::fs::remove_dir_all(dir).unwrap();
161 }
162}