Skip to main content

jhol_core/
install.rs

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
9/// Package name without version: lodash@4 -> lodash, @scope/pkg@1.0 -> @scope/pkg
10fn base_name(package: &str) -> &str {
11    if let Some(idx) = package.rfind('@') {
12        // "@scope/pkg" (no version) or paths containing scoped names have '/' after '@'.
13        // A version suffix never contains '/'.
14        if idx > 0 && !package[idx + 1..].contains('/') {
15            return &package[..idx];
16        }
17    }
18    package
19}
20
21/// Read version from node_modules/<base>/package.json (base may be @scope/pkg)
22fn 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    /// When true, specs came from lockfile; skip npm show and use tarball URLs only (no packument).
37    pub from_lockfile: bool,
38    /// When true, never call Bun/npm; fail with clear error if native install fails.
39    pub native_only: bool,
40    /// When true, skip lifecycle scripts in backend fallback paths.
41    pub no_scripts: bool,
42    /// Optional allowlist for packages allowed to run scripts in backend fallback mode.
43    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
63/// Only update lockfile (no node_modules). Uses native resolver and lockfile writer.
64pub 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
95/// Install dependencies from package.json (and optional package-lock.json or bun.lock). Returns list of specs to install.
96/// If strict_lockfile is true, requires lockfile to exist and all deps to be in lockfile.
97pub 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    // When lockfile URLs are available, prefer full resolved spec list (top-level + transitive)
124    // so native lockfile/offline installs can be deterministic and complete.
125    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
136/// Install packages. Uses parallel validation, cache (content-addressable), native registry with backend fallback.
137pub 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    // Skip npm show when we trust the lockfile or frozen (zero packument)
177    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    // Install from cache: link from unpacked store, or fall back to backend/copy
186    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        // Zero packument: use lockfile URLs and integrity when present, parallel download, then extract
244        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    // Fallback: backend install for any that native failed
355    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}