Skip to main content

jhol_core/
backend.rs

1//! Backend abstraction: Bun or npm. Install, doctor, and audit are now native by default.
2//! Backend is only used when the user passes --fallback-backend (install) or similar opt-in.
3
4use std::process::Command;
5
6use crate::utils::{run_command_timeout, NPM_INSTALL_TIMEOUT_SECS};
7
8const OUTDATED_TIMEOUT_SECS: u64 = 30;
9
10/// Which package manager backend to use.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum Backend {
13    Bun,
14    Npm,
15}
16
17/// Detect if `bun` is available in PATH.
18pub fn bun_available() -> bool {
19    Command::new("bun")
20        .arg("--version")
21        .stdout(std::process::Stdio::null())
22        .stderr(std::process::Stdio::null())
23        .status()
24        .map(|s| s.success())
25        .unwrap_or(false)
26}
27
28/// Resolve backend: if explicit is Some use it (and check availability); else default to Bun if available, else Npm.
29pub fn resolve_backend(explicit: Option<Backend>) -> Backend {
30    match explicit {
31        Some(Backend::Bun) => {
32            if bun_available() {
33                Backend::Bun
34            } else {
35                Backend::Npm
36            }
37        }
38        Some(Backend::Npm) => Backend::Npm,
39        None => {
40            if bun_available() {
41                Backend::Bun
42            } else {
43                Backend::Npm
44            }
45        }
46    }
47}
48
49/// Install packages via backend. specs are e.g. ["lodash@4.17.21", "react@18"].
50pub fn backend_install(
51    specs: &[&str],
52    backend: Backend,
53    lockfile_only: bool,
54    no_scripts: bool,
55) -> Result<(), String> {
56    if specs.is_empty() {
57        return Ok(());
58    }
59    match backend {
60        Backend::Bun => {
61            let mut args = vec!["add"];
62            if lockfile_only {
63                args.push("--lockfile-only");
64            }
65            if no_scripts {
66                args.push("--ignore-scripts");
67            }
68            for s in specs {
69                args.push(s);
70            }
71            let out = run_command_timeout("bun", &args, NPM_INSTALL_TIMEOUT_SECS)
72                .map_err(|e| format!("bun add: {}", e))?;
73            if !out.status.success() {
74                let stderr = String::from_utf8_lossy(&out.stderr);
75                return Err(format!("bun add failed: {}", stderr));
76            }
77            Ok(())
78        }
79        Backend::Npm => {
80            let mut args = vec!["install"];
81            if lockfile_only {
82                args.push("--package-lock-only");
83            }
84            if no_scripts {
85                args.push("--ignore-scripts");
86            }
87            for s in specs {
88                args.push(s);
89            }
90            let out = run_command_timeout("npm", &args, NPM_INSTALL_TIMEOUT_SECS)
91                .map_err(|e| format!("npm install: {}", e))?;
92            if !out.status.success() {
93                let stderr = String::from_utf8_lossy(&out.stderr);
94                return Err(format!("npm install failed: {}", stderr));
95            }
96            Ok(())
97        }
98    }
99}
100
101/// Install from package.json only (no spec list). Used for `jhol install` with no args.
102pub fn backend_install_from_package_json(
103    backend: Backend,
104    lockfile_only: bool,
105    no_scripts: bool,
106) -> Result<(), String> {
107    match backend {
108        Backend::Bun => {
109            let mut args = vec!["install"];
110            if lockfile_only {
111                args.push("--lockfile-only");
112            }
113            if no_scripts {
114                args.push("--ignore-scripts");
115            }
116            let out = run_command_timeout("bun", &args, NPM_INSTALL_TIMEOUT_SECS)
117                .map_err(|e| format!("bun install: {}", e))?;
118            if !out.status.success() {
119                let stderr = String::from_utf8_lossy(&out.stderr);
120                return Err(format!("bun install failed: {}", stderr));
121            }
122            Ok(())
123        }
124        Backend::Npm => {
125            let mut args = vec!["install"];
126            if lockfile_only {
127                args.push("--package-lock-only");
128            }
129            if no_scripts {
130                args.push("--ignore-scripts");
131            }
132            let out = run_command_timeout("npm", &args, NPM_INSTALL_TIMEOUT_SECS)
133                .map_err(|e| format!("npm install: {}", e))?;
134            if !out.status.success() {
135                let stderr = String::from_utf8_lossy(&out.stderr);
136                return Err(format!("npm install failed: {}", stderr));
137            }
138            Ok(())
139        }
140    }
141}
142
143/// Run outdated check; returns JSON object mapping package name to { current, wanted, latest }.
144/// For Bun we use npm outdated (Bun doesn't have a direct equivalent with JSON); or we could use registry + lockfile.
145pub fn backend_outdated_json(backend: Backend) -> Option<serde_json::Value> {
146    match backend {
147        Backend::Npm => {
148            let out = run_command_timeout("npm", &["outdated", "--json"], OUTDATED_TIMEOUT_SECS).ok()?;
149            let s = String::from_utf8_lossy(&out.stdout);
150            serde_json::from_str(&s).ok()
151        }
152        Backend::Bun => {
153            // Bun doesn't have `bun outdated --json`. Use npm outdated for compatibility when bun is backend
154            // (project may still have package-lock.json or we run in node_modules context).
155            let out = run_command_timeout("npm", &["outdated", "--json"], OUTDATED_TIMEOUT_SECS).ok()?;
156            let s = String::from_utf8_lossy(&out.stdout);
157            serde_json::from_str(&s).ok()
158        }
159    }
160}
161
162/// Fix outdated packages by installing latest. packages = list of package names.
163pub fn backend_fix_packages(packages: &[String], backend: Backend, quiet: bool) -> Result<(), String> {
164    if packages.is_empty() {
165        return Ok(());
166    }
167    let specs: Vec<String> = packages.iter().map(|p| format!("{}@latest", p)).collect();
168    let refs: Vec<&str> = specs.iter().map(String::as_str).collect();
169    if !quiet {
170        println!("Updating {} package(s) via {}...", packages.len(), backend_name(backend));
171    }
172    backend_install(&refs, backend, false, true)
173}
174
175fn backend_name(b: Backend) -> &'static str {
176    match b {
177        Backend::Bun => "bun",
178        Backend::Npm => "npm",
179    }
180}
181
182const AUDIT_TIMEOUT_SECS: u64 = 60;
183
184/// Run audit (check for vulnerabilities). Returns raw JSON bytes from backend.
185/// Backend may exit non-zero when vulns are found; we still return stdout.
186pub fn backend_audit(backend: Backend) -> Result<Vec<u8>, String> {
187    match backend {
188        Backend::Bun => {
189            let out = run_command_timeout("bun", &["audit", "--json"], AUDIT_TIMEOUT_SECS)
190                .map_err(|e| format!("bun audit: {}", e))?;
191            Ok(out.stdout)
192        }
193        Backend::Npm => {
194            let out = run_command_timeout("npm", &["audit", "--json"], AUDIT_TIMEOUT_SECS)
195                .map_err(|e| format!("npm audit: {}", e))?;
196            Ok(out.stdout)
197        }
198    }
199}
200
201/// Run audit fix. Returns success and stderr for messaging.
202pub fn backend_audit_fix(backend: Backend) -> Result<(), String> {
203    match backend {
204        Backend::Bun => {
205            let out = run_command_timeout("bun", &["audit", "fix"], AUDIT_TIMEOUT_SECS)
206                .map_err(|e| format!("bun audit fix: {}", e))?;
207            if !out.status.success() {
208                let stderr = String::from_utf8_lossy(&out.stderr);
209                return Err(format!("bun audit fix failed: {}", stderr));
210            }
211            Ok(())
212        }
213        Backend::Npm => {
214            let out = run_command_timeout("npm", &["audit", "fix"], AUDIT_TIMEOUT_SECS)
215                .map_err(|e| format!("npm audit fix: {}", e))?;
216            if !out.status.success() {
217                let stderr = String::from_utf8_lossy(&out.stderr);
218                return Err(format!("npm audit fix failed: {}", stderr));
219            }
220            Ok(())
221        }
222    }
223}
224
225/// Install from cache (tarball paths) using backend. Both bun and npm accept local paths.
226pub fn backend_install_tarballs(
227    paths: &[std::path::PathBuf],
228    backend: Backend,
229    no_scripts: bool,
230) -> Result<(), String> {
231    if paths.is_empty() {
232        return Ok(());
233    }
234    let path_strs: Vec<String> = paths
235        .iter()
236        .map(|p| p.to_string_lossy().into_owned())
237        .collect();
238    let refs: Vec<&str> = path_strs.iter().map(String::as_str).collect();
239    match backend {
240        Backend::Bun => {
241            let mut args = vec!["add"];
242            if no_scripts {
243                args.push("--ignore-scripts");
244            }
245            args.extend(refs);
246            let out = run_command_timeout("bun", &args, NPM_INSTALL_TIMEOUT_SECS)
247                .map_err(|e| format!("bun add (cache): {}", e))?;
248            if !out.status.success() {
249                let stderr = String::from_utf8_lossy(&out.stderr);
250                return Err(format!("bun add failed: {}", stderr));
251            }
252            Ok(())
253        }
254        Backend::Npm => {
255            let mut args = vec!["install"];
256            if no_scripts {
257                args.push("--ignore-scripts");
258            }
259            args.extend(refs);
260            let out = run_command_timeout("npm", &args, NPM_INSTALL_TIMEOUT_SECS)
261                .map_err(|e| format!("npm install (cache): {}", e))?;
262            if !out.status.success() {
263                let stderr = String::from_utf8_lossy(&out.stderr);
264                return Err(format!("npm install failed: {}", stderr));
265            }
266            Ok(())
267        }
268    }
269}