npm_utils/install/
node_modules.rs1use std::path::Path;
5
6use crate::package_json::spec::{Range, Spec};
7use serde_json::Value;
8
9use crate::path_safety::safe_join;
10use crate::registry::{Registry, Resolved};
11
12pub fn node_modules(
17 package_json: &Path,
18 dest: &Path,
19) -> Result<Vec<Resolved>, Box<dyn std::error::Error>> {
20 let roots = root_requirements(package_json)?;
21 let resolved = Registry::npm().resolve_tree(&roots)?;
22 let want = resolved
23 .iter()
24 .map(|r| format!("{}@{}", r.name, r.version))
25 .collect::<Vec<_>>()
26 .join("\n");
27
28 super::run_install(dest, &want, |node_modules| {
29 for pkg in &resolved {
30 let dir = safe_join(node_modules, &pkg.name)?;
31 super::fetch_verify_extract(
32 &pkg.name,
33 &pkg.tarball_url,
34 pkg.integrity.as_deref(),
35 &dir,
36 )?;
37 }
38 Ok(())
39 })?;
40
41 Ok(resolved)
42}
43
44fn root_requirements(
48 package_json: &Path,
49) -> Result<Vec<(String, Range)>, Box<dyn std::error::Error>> {
50 let json: Value = serde_json::from_str(&std::fs::read_to_string(package_json)?)?;
51 let deps = json
52 .get("dependencies")
53 .and_then(Value::as_object)
54 .ok_or("no dependencies section in package.json")?;
55 let mut out = Vec::new();
56 for (name, value) in deps {
57 let Some(spec) = value.as_str() else { continue };
58 if !Spec::parse(spec).is_registry() {
59 return Err(format!(
60 "dependency `{name}`: {spec:?} is not a registry spec — git/tarball/local specs \
61 aren't installable from the registry"
62 )
63 .into());
64 }
65 let req = Range::parse(spec)
66 .map_err(|e| format!("dependency `{name}`: unsupported version {spec:?}: {e}"))?;
67 out.push((name.clone(), req));
68 }
69 Ok(out)
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use tempfile::tempdir;
76
77 #[test]
78 fn root_requirements_classifies_via_spec() {
79 let tmp = tempdir().unwrap();
80 let pkg = tmp.path().join("package.json");
81
82 std::fs::write(&pkg, r#"{"dependencies":{"x":"github:owner/repo#abc"}}"#).unwrap();
84 assert!(root_requirements(&pkg).is_err());
85
86 std::fs::write(&pkg, r#"{"dependencies":{"lit":"^3"}}"#).unwrap();
88 let reqs = root_requirements(&pkg).unwrap();
89 assert_eq!(reqs.len(), 1);
90 assert_eq!(reqs[0].0, "lit");
91 }
92
93 #[test]
94 #[ignore = "network: hits the npm registry"]
95 fn installs_react_with_transitive_scheduler() {
96 let tmp = tempdir().unwrap();
102 let pkg = tmp.path().join("package.json");
103 std::fs::write(
104 &pkg,
105 r#"{ "dependencies": { "react": "^19", "react-dom": "^19" } }"#,
106 )
107 .unwrap();
108
109 let resolved = node_modules(&pkg, tmp.path()).unwrap();
110 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
111 assert!(names.contains(&"react"), "got {names:?}");
112 assert!(names.contains(&"react-dom"), "got {names:?}");
113 assert!(
114 names.contains(&"scheduler"),
115 "transitive dep missing: {names:?}"
116 );
117
118 let nm = tmp.path().join("node_modules");
119 for p in ["react", "react-dom", "scheduler"] {
120 assert!(
121 nm.join(p).join("package.json").is_file(),
122 "node_modules/{p}/package.json missing"
123 );
124 }
125 }
126
127 #[test]
128 #[ignore = "network: hits the npm registry"]
129 fn downloads_and_extracts_a_commonjs_package() {
130 use crate::package_json::{PackageJson, PackageType};
131 let tmp = tempdir().unwrap();
135 let pkg = tmp.path().join("package.json");
136 std::fs::write(&pkg, r#"{ "dependencies": { "ms": "^2" } }"#).unwrap();
137
138 let resolved = node_modules(&pkg, tmp.path()).unwrap();
139 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
140 assert_eq!(names, ["ms"], "ms has no runtime dependencies");
141
142 let ms = tmp.path().join("node_modules/ms");
143 let manifest = PackageJson::from_path(&ms.join("package.json")).unwrap();
144 assert_eq!(manifest.name(), Some("ms"));
145 assert_eq!(
146 manifest.package_type(),
147 PackageType::CommonJs,
148 "ms ships CommonJS"
149 );
150 let entry = ms.join("index.js");
151 let source = std::fs::read_to_string(&entry).unwrap();
152 assert!(
153 source.contains("module.exports"),
154 "extracted entry {entry:?} is CommonJS source"
155 );
156 }
157}