1use std::collections::HashSet;
2use std::fs;
3use std::path::Path;
4use crate::backend::{self, Backend};
5use crate::lockfile;
6use crate::registry;
7use crate::utils::{self, NPM_SHOW_TIMEOUT_SECS};
8
9fn base_name(package: &str) -> &str {
11 if let Some(idx) = package.rfind('@') {
12 if idx > 0 && !package[idx + 1..].contains('/') {
15 return &package[..idx];
16 }
17 }
18 package
19}
20
21fn read_installed_version(base: &str) -> Option<String> {
23 let path = Path::new("node_modules").join(base).join("package.json");
24 let s = fs::read_to_string(path).ok()?;
25 let v: serde_json::Value = serde_json::from_str(&s).ok()?;
26 v.get("version")?.as_str().map(String::from)
27}
28
29pub struct InstallOptions {
30 pub no_cache: bool,
31 pub quiet: bool,
32 pub backend: Backend,
33 pub lockfile_only: bool,
34 pub offline: bool,
35 pub strict_lockfile: bool,
36 pub from_lockfile: bool,
38 pub native_only: bool,
40 pub no_scripts: bool,
42 pub script_allowlist: Option<std::collections::HashSet<String>>,
44}
45
46impl Default for InstallOptions {
47 fn default() -> Self {
48 Self {
49 no_cache: false,
50 quiet: false,
51 backend: backend::resolve_backend(None),
52 lockfile_only: false,
53 offline: false,
54 strict_lockfile: false,
55 from_lockfile: false,
56 native_only: true,
57 no_scripts: true,
58 script_allowlist: None,
59 }
60 }
61}
62
63pub fn install_lockfile_only(_backend: Backend) -> Result<(), String> {
65 let pj = Path::new("package.json");
66 if !pj.exists() {
67 return Err("No package.json found in current directory.".to_string());
68 }
69 let tree = crate::lockfile_write::resolve_full_tree(pj)?;
70 let lock_path = Path::new("package-lock.json");
71 crate::lockfile_write::write_package_lock(lock_path, pj, &tree)?;
72 Ok(())
73}
74
75fn check_script_allowlist(packages: &[String], allowlist: &std::collections::HashSet<String>) -> Result<(), String> {
76 let mut denied = Vec::new();
77 for p in packages {
78 let name = base_name(p).to_string();
79 if !allowlist.contains(&name) {
80 denied.push(name);
81 }
82 }
83 if denied.is_empty() {
84 Ok(())
85 } else {
86 denied.sort();
87 denied.dedup();
88 Err(format!(
89 "Scripts are only allowed for allowlisted packages. Denied: {}",
90 denied.join(", ")
91 ))
92 }
93}
94
95pub fn resolve_install_from_package_json(strict_lockfile: bool) -> Result<Vec<String>, String> {
98 let pj_path = Path::new("package.json");
99 if !pj_path.exists() {
100 return Err("No package.json found in current directory.".to_string());
101 }
102 let deps = lockfile::read_package_json_deps(pj_path)
103 .ok_or("Could not read package.json dependencies.")?;
104 if deps.is_empty() {
105 return Ok(Vec::new());
106 }
107 let resolved = lockfile::read_resolved_from_dir(Path::new("."));
108 if strict_lockfile {
109 if resolved.is_none() {
110 return Err("Strict lockfile required but no package-lock.json or bun.lock found. Run install without --frozen first.".to_string());
111 }
112 if !lockfile::lockfile_integrity_complete(Path::new(".")) {
113 return Err("Strict lockfile: integrity entries missing. Run install without --frozen to regenerate lockfile with integrity.".to_string());
114 }
115 let r = resolved.as_ref().unwrap();
116 for name in deps.keys() {
117 if !r.contains_key(name) {
118 return Err(format!("Strict lockfile: dependency {} not in lockfile. Run install without --frozen to update lockfile.", name));
119 }
120 }
121 }
122
123 if let Some(mut specs) = lockfile::read_all_resolved_specs_from_dir(Path::new(".")) {
126 if !specs.is_empty() {
127 specs.sort();
128 specs.dedup();
129 return Ok(specs);
130 }
131 }
132
133 Ok(lockfile::resolve_deps_for_install(&deps, resolved.as_ref()))
134}
135
136pub fn install_package(packages: &[&str], options: &InstallOptions) -> Result<(), String> {
138 let mut seen_packages = HashSet::new();
139 let mut to_install_from_cache = Vec::new();
140 let mut to_fetch = Vec::new();
141 let mut missing_for_offline = Vec::new();
142
143 for package in packages {
144 let base = base_name(package);
145 if seen_packages.contains(base) {
146 if !options.quiet {
147 println!("Warning: Multiple versions of {} requested.", base);
148 }
149 }
150 seen_packages.insert(base.to_string());
151 utils::log(&format!("Installing package: {}", package));
152
153 if !options.no_cache {
154 if let Some(tarball) = utils::get_cached_tarball(package) {
155 if !options.quiet {
156 println!("Installing {} from cache...", package);
157 }
158 to_install_from_cache.push((package.to_string(), tarball));
159 continue;
160 }
161 }
162 if options.offline {
163 missing_for_offline.push(package.to_string());
164 continue;
165 }
166 to_fetch.push(package.to_string());
167 }
168
169 if !missing_for_offline.is_empty() {
170 return Err(format!(
171 "Offline mode: package(s) not in cache: {}. Run without --offline to fetch.",
172 missing_for_offline.join(", ")
173 ));
174 }
175
176 if !to_fetch.is_empty() && !options.from_lockfile && !options.strict_lockfile {
178 let results = registry::parallel_validate_packages(&to_fetch, NPM_SHOW_TIMEOUT_SECS);
179 let invalid: Vec<String> = results.iter().filter(|(_, ok)| !*ok).map(|(p, _)| p.clone()).collect();
180 if !invalid.is_empty() {
181 return Err(format!("Package(s) not found or invalid: {}", invalid.join(", ")));
182 }
183 }
184
185 if !to_install_from_cache.is_empty() {
187 let cache_dir = std::path::PathBuf::from(utils::get_cache_dir());
188 let node_modules = Path::new("node_modules");
189 std::fs::create_dir_all(node_modules).map_err(|e| e.to_string())?;
190 let mut fallback_tarballs = Vec::new();
191 for (pkg, tarball_path) in &to_install_from_cache {
192 let base = base_name(pkg);
193 match registry::ensure_unpacked_in_store(tarball_path, &cache_dir) {
194 Ok(unpacked) => {
195 if utils::link_package_from_store(&unpacked, node_modules, base).is_ok() {
196 utils::log(&format!("Installed {} from cache (link).", pkg));
197 } else if registry::extract_tarball(tarball_path, node_modules, base).is_ok() {
198 utils::log(&format!("Installed {} from cache (copy).", pkg));
199 } else {
200 fallback_tarballs.push((pkg.clone(), tarball_path.clone()));
201 }
202 }
203 Err(_) => fallback_tarballs.push((pkg.clone(), tarball_path.clone())),
204 }
205 }
206 if !fallback_tarballs.is_empty() {
207 if options.native_only {
208 let pkgs: Vec<String> = fallback_tarballs.iter().map(|(p, _)| p.clone()).collect();
209 return Err(format!(
210 "Native-only: could not link or extract from cache for: {}. Try JHOL_LINK=0 or run without --native-only.",
211 pkgs.join(", ")
212 ));
213 }
214 if !options.no_scripts {
215 if let Some(allowlist) = &options.script_allowlist {
216 let pkgs: Vec<String> = fallback_tarballs.iter().map(|(p, _)| p.clone()).collect();
217 check_script_allowlist(&pkgs, allowlist)?;
218 }
219 }
220 let paths: Vec<std::path::PathBuf> = fallback_tarballs.iter().map(|(_, p)| p.clone()).collect();
221 match backend::backend_install_tarballs(&paths, options.backend, options.no_scripts) {
222 Ok(()) => {
223 for (pkg, _) in &fallback_tarballs {
224 utils::log(&format!("Installed {} from cache (backend).", pkg));
225 }
226 }
227 Err(e) => return Err(e),
228 }
229 }
230 }
231
232 if to_fetch.is_empty() {
233 return Ok(());
234 }
235
236 let cache_dir = std::path::PathBuf::from(utils::get_cache_dir());
237 let node_modules = Path::new("node_modules");
238 std::fs::create_dir_all(node_modules).map_err(|e| e.to_string())?;
239
240 let mut npm_fallback = Vec::new();
241 let mut index_batch: std::collections::HashMap<String, String> = std::collections::HashMap::new();
242 if options.from_lockfile {
243 let (resolved_urls, resolved_integrity) = match lockfile::read_resolved_urls_and_integrity_from_dir(Path::new(".")) {
245 Some((u, i)) => (u, i),
246 None => (std::collections::HashMap::new(), std::collections::HashMap::new()),
247 };
248 let mut work: Vec<(String, String, Option<String>)> = Vec::new();
249 for pkg in &to_fetch {
250 if options.no_cache {
251 npm_fallback.push(pkg.clone());
252 continue;
253 }
254 let url = resolved_urls
255 .get(pkg)
256 .cloned()
257 .or_else(|| {
258 let base = base_name(pkg);
259 let version = pkg.rfind('@').map(|i| &pkg[i + 1..]).unwrap_or("latest");
260 Some(lockfile::tarball_url_from_registry(base, version))
261 });
262 match url {
263 Some(u) => {
264 let integrity = resolved_integrity.get(pkg).cloned();
265 work.push((pkg.clone(), u, integrity));
266 }
267 None => npm_fallback.push(pkg.clone()),
268 }
269 }
270 const DL_CONCURRENCY: usize = 8;
271 let mut download_results: Vec<(String, Result<String, String>)> = Vec::with_capacity(work.len());
272 for chunk in work.chunks(DL_CONCURRENCY) {
273 use std::sync::mpsc;
274 use std::thread;
275 let (tx, rx) = mpsc::channel();
276 for (pkg, url, integrity) in chunk {
277 let pkg = pkg.clone();
278 let url = url.clone();
279 let integrity = integrity.clone();
280 let cache_dir = cache_dir.clone();
281 let tx = tx.clone();
282 thread::spawn(move || {
283 let res = registry::download_tarball_to_store_hash_only(
284 &url,
285 &cache_dir,
286 &pkg,
287 integrity.as_deref(),
288 );
289 let _ = tx.send((pkg, res));
290 });
291 }
292 drop(tx);
293 for (pkg, res) in rx {
294 download_results.push((pkg, res));
295 }
296 }
297 for (pkg, res) in download_results {
298 match res {
299 Ok(hash) => {
300 index_batch.insert(pkg.clone(), hash.clone());
301 let store_path = cache_dir.join("store").join(format!("{}.tgz", hash));
302 let base = base_name(&pkg);
303 if let Err(e) = registry::extract_tarball(&store_path, node_modules, base) {
304 let msg = format!("Extract failed for {}: {}", pkg, e);
305 utils::log(&msg);
306 npm_fallback.push(pkg);
307 continue;
308 }
309 if !options.quiet {
310 let version = pkg.rfind('@').map(|i| &pkg[i + 1..]).unwrap_or("");
311 println!("Installed {}@{} (native)", base, version);
312 }
313 }
314 Err(_) => npm_fallback.push(pkg),
315 }
316 }
317 if !index_batch.is_empty() {
318 let mut index = utils::read_store_index();
319 index.extend(index_batch);
320 utils::write_store_index(&index).map_err(|e| e.to_string())?;
321 }
322 } else {
323 for pkg in &to_fetch {
324 if options.no_cache {
325 npm_fallback.push(pkg.clone());
326 continue;
327 }
328 match registry::install_package_native(pkg, node_modules, &cache_dir, options) {
329 Ok(()) => {}
330 Err(_) => {
331 npm_fallback.push(pkg.clone());
332 }
333 }
334 }
335 }
336
337 if npm_fallback.is_empty() {
338 return Ok(());
339 }
340
341 if options.native_only {
342 return Err(format!(
343 "Native-only: install failed for: {}. Run without --native-only to use Bun/npm fallback.",
344 npm_fallback.join(", ")
345 ));
346 }
347
348 if !options.no_scripts {
349 if let Some(allowlist) = &options.script_allowlist {
350 check_script_allowlist(&npm_fallback, allowlist)?;
351 }
352 }
353
354 let fetch_refs: Vec<&str> = npm_fallback.iter().map(|s| s.as_str()).collect();
356 let mut attempts = 3;
357 loop {
358 match backend::backend_install(
359 &fetch_refs,
360 options.backend,
361 options.lockfile_only,
362 options.no_scripts,
363 ) {
364 Ok(()) => {
365 let cache_dir = std::path::PathBuf::from(utils::get_cache_dir());
366 for pkg in &npm_fallback {
367 let base = base_name(pkg);
368 if let Some(version) = read_installed_version(base) {
369 let _ = registry::fill_store_from_registry(base, &version, &cache_dir);
370 }
371 utils::log(&format!("Installed {} via backend.", pkg));
372 }
373 return Ok(());
374 }
375 Err(e) => {
376 if attempts <= 1 {
377 return Err(e);
378 }
379 if !options.quiet {
380 eprintln!("Install failed, retrying in 2s...");
381 }
382 }
383 }
384 attempts -= 1;
385 std::thread::sleep(std::time::Duration::from_secs(2));
386 }
387}