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