1use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use async_trait::async_trait;
9use serde::Deserialize;
10
11use crate::backend::{Backend, Ctx, InstallCtx};
12use crate::error::Result;
13use crate::http;
14use crate::pipeline::{self, ArchiveKind, Checksum, HashAlgo, InstallPlan, PipelineCtx};
15use crate::platform::{Arch, Os};
16use crate::source::Source;
17use crate::version::{select_version, ToolRequest, ToolVersion, VersionInfo, VersionSpec};
18
19pub struct NodeBackend;
20
21#[derive(Debug, Deserialize)]
22struct NodeRelease {
23 version: String, #[serde(default)]
25 files: Vec<String>,
26 #[serde(default)]
28 lts: LtsField,
29}
30
31#[derive(Debug, Default)]
32enum LtsField {
33 #[default]
34 No,
35 Named(String),
36}
37
38impl<'de> Deserialize<'de> for LtsField {
39 fn deserialize<D>(d: D) -> std::result::Result<Self, D::Error>
40 where
41 D: serde::Deserializer<'de>,
42 {
43 let v = serde_json::Value::deserialize(d)?;
44 match v {
45 serde_json::Value::String(s) => Ok(LtsField::Named(s)),
46 _ => Ok(LtsField::No),
47 }
48 }
49}
50
51impl NodeBackend {
52 fn target_arch(ctx: &Ctx, options: &BTreeMap<String, String>) -> Result<Arch> {
55 let Some(value) = options.get("arch") else {
56 return Ok(ctx.platform.arch);
57 };
58 Arch::parse_node(value).ok_or_else(|| {
59 crate::error::Error::config(format!(
60 "unsupported Node architecture `{value}` (expected x64|arm64|x86|arm)"
61 ))
62 })
63 }
64
65 fn file_token(ctx: &Ctx, arch: Arch) -> String {
66 let arch = arch.node_token();
67 match ctx.platform.os {
68 Os::Linux => format!("linux-{arch}"),
69 Os::Macos => format!("osx-{arch}-tar"),
70 Os::Windows => format!("win-{arch}-zip"),
71 }
72 }
73
74 fn archive_for(ctx: &Ctx, arch: Arch, version: &str) -> (String, ArchiveKind) {
76 let os = ctx.platform.os.node_token();
77 let arch = arch.node_token();
78 match ctx.platform.os {
79 Os::Windows => (format!("node-v{version}-{os}-{arch}.zip"), ArchiveKind::Zip),
80 _ => (
81 format!("node-v{version}-{os}-{arch}.tar.gz"),
82 ArchiveKind::TarGz,
83 ),
84 }
85 }
86
87 fn corepack_enabled(ctx: &Ctx, tv: &ToolVersion) -> Result<bool> {
88 match tv.options.get("corepack").map(String::as_str) {
89 Some("true" | "1" | "yes" | "on") => Ok(true),
90 Some("false" | "0" | "no" | "off") => Ok(false),
91 Some(value) => Err(crate::error::Error::config(format!(
92 "invalid Node corepack option `{value}` (expected true|false)"
93 ))),
94 None => Ok(ctx.config.settings.node.corepack),
95 }
96 }
97
98 fn enable_corepack(ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
99 if !Self::corepack_enabled(ctx, tv)? {
100 return Ok(());
101 }
102 let bin_dir = match ctx.platform.os {
103 Os::Windows => ctx.dirs.install_path("node", &tv.version),
104 _ => ctx.dirs.install_path("node", &tv.version).join("bin"),
105 };
106 let executable = match ctx.platform.os {
107 Os::Windows => bin_dir.join("corepack.cmd"),
108 _ => bin_dir.join("corepack"),
109 };
110 if !executable.is_file() {
111 return Err(crate::error::Error::other(format!(
112 "Node {} does not include Corepack at {}",
113 tv.version,
114 executable.display()
115 )));
116 }
117 let inherited = std::env::var_os("PATH").unwrap_or_default();
118 let mut paths = vec![bin_dir.clone()];
119 paths.extend(std::env::split_paths(&inherited));
120 let mut env = BTreeMap::new();
121 env.insert(
122 "PATH".into(),
123 std::env::join_paths(paths)
124 .map_err(|error| crate::error::Error::other(error.to_string()))?
125 .to_string_lossy()
126 .into_owned(),
127 );
128 let install_directory = bin_dir.display().to_string();
129 match ctx.platform.os {
130 Os::Windows => {
131 let script = executable.display().to_string();
132 crate::process::run(
133 "cmd",
134 &[
135 "/D",
136 "/S",
137 "/C",
138 &script,
139 "enable",
140 "--install-directory",
141 &install_directory,
142 ],
143 &env,
144 Some(&bin_dir),
145 )
146 }
147 _ => crate::process::run(
148 &executable.display().to_string(),
149 &["enable", "--install-directory", &install_directory],
150 &env,
151 Some(&bin_dir),
152 ),
153 }
154 }
155
156 fn complete_install(ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
157 if let Err(error) = Self::enable_corepack(ctx, tv) {
158 let install = ctx.dirs.install_path("node", &tv.version);
159 let _ = std::fs::remove_dir_all(install);
160 return Err(error);
161 }
162 Ok(())
163 }
164}
165
166#[async_trait]
167impl Backend for NodeBackend {
168 fn id(&self) -> &str {
169 "node"
170 }
171
172 fn aliases(&self) -> &[&str] {
173 &["nodejs"]
174 }
175
176 fn default_sources(&self) -> Vec<Source> {
177 vec![
178 Source::official("official", "https://nodejs.org/dist/")
179 .with_index("https://nodejs.org/dist/index.json"),
180 Source::mirror("npmmirror", "https://npmmirror.com/mirrors/node/", 10)
181 .with_index("https://npmmirror.com/mirrors/node/index.json"),
182 Source::mirror(
183 "tuna",
184 "https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/",
185 20,
186 )
187 .with_index("https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/index.json"),
188 Source::mirror("ustc", "https://mirrors.ustc.edu.cn/node/", 30)
189 .with_index("https://mirrors.ustc.edu.cn/node/index.json"),
190 ]
191 }
192
193 fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
194 source.index_url.clone()
196 }
197
198 async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
199 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
200 let token = Self::file_token(ctx, ctx.platform.arch);
202
203 use std::collections::BTreeMap;
207 let mut merged: BTreeMap<String, Option<String>> = BTreeMap::new();
208 let mut any_ok = false;
209 let mut last_err: Option<crate::error::Error> = None;
210 for source in &sources {
211 let index_url = source
212 .index_url
213 .clone()
214 .unwrap_or_else(|| http::join_url(&source.download_url, "index.json"));
215 let releases: Vec<NodeRelease> = match http::get_cached_json(ctx, &index_url).await {
216 Ok(r) => r,
217 Err(e) => {
218 tracing::warn!(source = %source.id, "{}", crate::i18n::trf("log.index_fetch_failover", &[("err", &e.to_string())]));
219 last_err = Some(e);
220 continue;
221 }
222 };
223 any_ok = true;
224 for r in releases {
225 if !(r.files.is_empty() || r.files.iter().any(|f| f == &token)) {
226 continue;
227 }
228 let version = r.version.trim_start_matches('v').to_string();
229 if version.is_empty() {
230 continue;
231 }
232 let lts = match r.lts {
233 LtsField::Named(s) => Some(s.to_lowercase()),
234 LtsField::No => None,
235 };
236 merged.entry(version).or_insert(lts);
237 }
238 if !merged.is_empty() {
241 if source.kind == crate::source::SourceKind::Official {
245 break;
246 }
247 if let Some(official) = sources
249 .iter()
250 .find(|s| s.kind == crate::source::SourceKind::Official)
251 {
252 if official.id != source.id {
253 if let Some(idx) = &official.index_url {
254 if let Ok(rel) =
255 http::get_cached_json::<Vec<NodeRelease>>(ctx, idx).await
256 {
257 for r in rel {
258 if !(r.files.is_empty() || r.files.iter().any(|f| f == &token))
259 {
260 continue;
261 }
262 let v = r.version.trim_start_matches('v').to_string();
263 if v.is_empty() {
264 continue;
265 }
266 let lts = match r.lts {
267 LtsField::Named(s) => Some(s.to_lowercase()),
268 LtsField::No => None,
269 };
270 merged.entry(v).or_insert(lts);
271 }
272 }
273 }
274 }
275 }
276 break;
277 }
278 }
279
280 if !any_ok {
281 return Err(
282 last_err.unwrap_or_else(|| crate::error::Error::NoUsableSource {
283 tool: self.id().to_string(),
284 tried: sources.len(),
285 }),
286 );
287 }
288
289 let mut out: Vec<VersionInfo> = merged
291 .into_iter()
292 .map(|(version, lts)| VersionInfo {
293 version,
294 stable: true,
295 lts,
296 })
297 .collect();
298 out.sort_by(|a, b| crate::backend::python::cmp_versions(&a.version, &b.version));
299 Ok(out)
300 }
301
302 async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
303 let target_arch = Self::target_arch(ctx, &req.options)?;
304 let corepack = match req.options.get("corepack") {
305 Some(value) => match value.as_str() {
306 "true" | "1" | "yes" | "on" => true,
307 "false" | "0" | "no" | "off" => false,
308 _ => {
309 return Err(crate::error::Error::config(format!(
310 "invalid Node corepack option `{value}` (expected true|false)"
311 )))
312 }
313 },
314 None => ctx.config.settings.node.corepack,
315 };
316 if let VersionSpec::Exact(version) = &req.spec {
317 let mut resolved = ToolVersion::new(self.id(), version);
318 resolved.options = req.options.clone();
319 resolved
320 .options
321 .insert("arch".into(), target_arch.node_token().into());
322 resolved
323 .options
324 .insert("corepack".into(), corepack.to_string());
325 return Ok(resolved);
326 }
327 let target_ctx = Ctx {
328 dirs: ctx.dirs.clone(),
329 platform: crate::platform::Platform {
330 arch: target_arch,
331 ..ctx.platform
332 },
333 config: ctx.config.clone(),
334 client: ctx.client.clone(),
335 cas: ctx.cas.clone(),
336 show_progress: ctx.show_progress,
337 };
338 let versions = self.list_remote_versions(&target_ctx).await?;
339 let chosen = select_version(&req.spec, &versions).ok_or_else(|| {
340 crate::error::Error::VersionResolve {
341 tool: self.id().to_string(),
342 spec: req.spec.to_string(),
343 hint: Some(format!(
344 "no matching version ships a {} asset",
345 target_arch.node_token()
346 )),
347 }
348 })?;
349 let mut resolved = ToolVersion::new(self.id(), &chosen.version);
350 resolved.options = req.options.clone();
351 resolved
352 .options
353 .insert("arch".into(), target_arch.node_token().into());
354 resolved
355 .options
356 .insert("corepack".into(), corepack.to_string());
357 Ok(resolved)
358 }
359
360 async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
361 let ctx = ictx.ctx;
362 let target_arch = Self::target_arch(ctx, &tv.options)?;
363 if target_arch != ctx.platform.arch {
364 return Err(crate::error::Error::config(format!(
365 "cannot execute Node {} artifacts on host {}; cross-architecture download-only mode is not available",
366 target_arch.node_token(),
367 ctx.platform.arch.node_token()
368 )));
369 }
370 if let Some(plan) = pipeline::locked_install_plan(self.id(), tv, true)? {
371 let pctx = PipelineCtx {
372 client: &ctx.client,
373 dirs: &ctx.dirs,
374 cas: &ctx.cas,
375 link_mode: ctx.config.settings.link_mode,
376 show_progress: ctx.show_progress,
377 offline: ctx.config.settings.offline,
378 require_checksums: ctx.config.settings.require_checksums,
379 };
380 pipeline::run(&plan, &pctx).await?;
381 Self::complete_install(ctx, tv)?;
382 return Ok(());
383 }
384 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
385 let version = &tv.version;
386 let (file_name, kind) = Self::archive_for(ctx, target_arch, version);
387
388 let urls: Vec<String> = sources
391 .iter()
392 .map(|s| {
393 let base = http::join_url(&s.download_url, &format!("v{version}"));
394 http::join_url(&base, &file_name)
395 })
396 .collect();
397
398 let mut checksum = None;
401 for s in &sources {
402 let base = http::join_url(&s.download_url, &format!("v{version}"));
403 let shasums_url = http::join_url(&base, "SHASUMS256.txt");
404 if let Ok(body) = http::get_cached_text(ctx, &shasums_url).await {
405 if let Some(h) = pipeline::verify::find_shasum(&body, &file_name) {
406 checksum = Some(Checksum {
407 algo: HashAlgo::Sha256,
408 hex: h.to_string(),
409 });
410 break;
411 }
412 }
413 }
414
415 let plan = InstallPlan {
416 tool: self.id().to_string(),
417 version: version.clone(),
418 urls,
419 file_name,
420 kind,
421 checksum,
422 strip_root: true,
423 subdir: None,
424 };
425 let pctx = PipelineCtx {
426 client: &ctx.client,
427 dirs: &ctx.dirs,
428 cas: &ctx.cas,
429 link_mode: ctx.config.settings.link_mode,
430 show_progress: ctx.show_progress,
431 offline: ctx.config.settings.offline,
432 require_checksums: ctx.config.settings.require_checksums,
433 };
434 pipeline::run(&plan, &pctx).await?;
435 Self::complete_install(ctx, tv)?;
436 Ok(())
437 }
438
439 fn ensure_post_install(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
440 Self::enable_corepack(ctx, tv)
441 }
442
443 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
444 let root = ctx.dirs.install_path(self.id(), &tv.version);
445 let dir = match ctx.platform.os {
447 Os::Windows => root,
448 _ => root.join("bin"),
449 };
450 Ok(vec![dir])
451 }
452
453 fn exec_env(&self, ctx: &Ctx, _tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
454 Ok(crate::cache::manager_exec_env(
455 &ctx.dirs.cache,
456 &[("npm_config_cache", "npm")],
457 ))
458 }
459
460 fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
461 let paths = self.bin_paths(ctx, tv)?;
464 let discovered = crate::backend::bin_names_in_dirs(&paths)
465 .into_iter()
466 .filter(|name| name != "npm" && name != "npx")
467 .collect::<Vec<_>>();
468 if discovered.is_empty() {
469 Ok(vec!["node".into(), "corepack".into()])
471 } else {
472 Ok(discovered)
473 }
474 }
475
476 fn idiomatic_files(&self) -> &[&str] {
477 &[".nvmrc", ".node-version"]
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use std::sync::Arc;
484
485 use super::*;
486 use crate::config::{Config, Settings, SourcesConfig};
487 use crate::dirs::Dirs;
488 use crate::platform::{Libc, Platform};
489 use crate::store::Cas;
490
491 fn test_ctx(root: &std::path::Path) -> Ctx {
492 let dirs = Dirs::resolve_from(|key| match key {
493 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
494 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
495 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
496 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
497 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
498 _ => None,
499 })
500 .unwrap();
501 dirs.ensure().unwrap();
502 Ctx {
503 cas: Arc::new(Cas::new(dirs.store.clone())),
504 dirs,
505 platform: Platform {
506 os: Os::Linux,
507 arch: Arch::X64,
508 libc: Libc::Glibc,
509 },
510 config: Config {
511 settings: Settings::default(),
512 sources: SourcesConfig::default(),
513 tools: Default::default(),
514 tool_configs: Default::default(),
515 global_tools: Default::default(),
516 global_tool_configs: Default::default(),
517 tool_origins: Default::default(),
518 aliases: Default::default(),
519 project_config_path: None,
520 },
521 client: reqwest::Client::new(),
522 show_progress: false,
523 }
524 }
525
526 #[tokio::test]
527 async fn resolution_records_effective_arch_and_corepack() {
528 let temp = tempfile::tempdir().unwrap();
529 let mut ctx = test_ctx(temp.path());
530 ctx.config.settings.node.corepack = true;
531 let request = ToolRequest::parse("node@20.11.1").unwrap();
532 let resolved = NodeBackend.resolve_version(&ctx, &request).await.unwrap();
533 assert_eq!(resolved.options["arch"], "x64");
534 assert_eq!(resolved.options["corepack"], "true");
535
536 let mut cross = ToolRequest::parse("node@20.11.1").unwrap();
537 cross.options.insert("arch".into(), "arm64".into());
538 let resolved = NodeBackend.resolve_version(&ctx, &cross).await.unwrap();
539 assert_eq!(resolved.options["arch"], "arm64");
540 let error = NodeBackend
541 .install(&InstallCtx { ctx: &ctx }, &resolved)
542 .await
543 .unwrap_err();
544 assert!(error.to_string().contains("cross-architecture"));
545 }
546
547 #[test]
548 fn bundled_npm_uses_the_shared_npm_cache() {
549 let temp = tempfile::tempdir().unwrap();
550 let ctx = test_ctx(temp.path());
551 let env = NodeBackend
552 .exec_env(&ctx, &ToolVersion::new("node", "20.11.1"))
553 .unwrap();
554 assert_eq!(
555 PathBuf::from(env.get("npm_config_cache").unwrap()),
556 ctx.dirs.cache.join("pkg/npm")
557 );
558 }
559
560 #[cfg(unix)]
561 #[test]
562 fn corepack_uses_managed_binary_and_failure_removes_install() {
563 use std::os::unix::fs::PermissionsExt;
564
565 let temp = tempfile::tempdir().unwrap();
566 let ctx = test_ctx(temp.path());
567 let mut version = ToolVersion::new("node", "20.11.1");
568 version.options.insert("corepack".into(), "true".into());
569 let install = ctx.dirs.install_path("node", &version.version);
570 let bin = install.join("bin");
571 std::fs::create_dir_all(&bin).unwrap();
572 let script = bin.join("corepack");
573 std::fs::write(
574 &script,
575 format!(
576 "#!/bin/sh\nprintf '%s|%s\\n' \"$PATH\" \"$*\" > '{}'\n",
577 temp.path().join("corepack.log").display()
578 ),
579 )
580 .unwrap();
581 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
582 NodeBackend::complete_install(&ctx, &version).unwrap();
583 let log = std::fs::read_to_string(temp.path().join("corepack.log")).unwrap();
584 assert!(log.starts_with(&bin.display().to_string()));
585 assert!(log.contains("enable --install-directory"));
586
587 std::fs::write(&script, "#!/bin/sh\nexit 7\n").unwrap();
588 let error = NodeBackend::complete_install(&ctx, &version).unwrap_err();
589 assert!(error.to_string().contains("failed"));
590 assert!(!install.exists());
591 }
592}