1use crate::discover::{self, InstallOrigin, InstalledNode};
7use crate::error::Error;
8use crate::http::Http;
9use crate::index;
10use crate::installer::{self, DownloadSpec};
11use crate::mise;
12use crate::platform::{Platform, artifact_filename, artifact_top_dir};
13use crate::progress::DownloadProgress;
14use crate::shasums::{self, sha256_from_sri, sri_sha256};
15use crate::spec::{NodeRequest, NodeSpec};
16use crate::{InstallerMode, PinnedNode, PinnedVariant, RuntimeConfig};
17use aube_manifest::OnFail;
18use std::collections::BTreeMap;
19use std::path::PathBuf;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ResolvedFrom {
24 PathEnv,
27 Installed(InstallOrigin),
29 FreshInstall(InstallOrigin),
31}
32
33#[derive(Debug, Clone)]
35pub struct Resolution {
36 pub version: node_semver::Version,
37 pub bin_dir: Option<PathBuf>,
39 pub node_bin: PathBuf,
40 pub from: ResolvedFrom,
41 pub fresh_pin: Option<PinnedNode>,
45}
46
47pub struct NodeRuntime {
48 pub(crate) cfg: RuntimeConfig,
49 pub(crate) http: Http,
50 memo: tokio::sync::Mutex<BTreeMap<String, Option<Resolution>>>,
51}
52
53impl NodeRuntime {
54 pub fn new(cfg: RuntimeConfig) -> Self {
55 let http = Http::new(cfg.retries);
56 NodeRuntime {
57 cfg,
58 http,
59 memo: tokio::sync::Mutex::new(BTreeMap::new()),
60 }
61 }
62
63 pub async fn resolve(
72 &self,
73 req: &NodeRequest,
74 pinned: Option<&PinnedNode>,
75 progress: &dyn DownloadProgress,
76 ) -> Result<Option<Resolution>, Error> {
77 let memo_key = match pinned {
78 Some(p) => format!("pin:{}", p.version),
79 None => format!("spec:{}", req.raw),
80 };
81 if let Some(hit) = self.memo.lock().await.get(&memo_key) {
82 return Ok(hit.clone());
83 }
84 let result = self.resolve_uncached(req, pinned, progress).await?;
85 self.memo.lock().await.insert(memo_key, result.clone());
86 Ok(result)
87 }
88
89 async fn resolve_uncached(
90 &self,
91 req: &NodeRequest,
92 pinned: Option<&PinnedNode>,
93 progress: &dyn DownloadProgress,
94 ) -> Result<Option<Resolution>, Error> {
95 let target = match pinned {
97 Some(p) => NodeSpec::Exact(p.version.clone()),
98 None => req.spec.clone(),
99 };
100
101 if let Some(resolution) = local_resolution(&target) {
104 return Ok(Some(resolution));
105 }
106
107 let locally_decidable = matches!(target, NodeSpec::Exact(_) | NodeSpec::Range(_));
115 if locally_decidable {
116 match req.on_fail {
117 OnFail::Ignore => return Ok(None),
118 OnFail::Warn => {
119 warn_version_mismatch(req);
120 return Ok(None);
121 }
122 OnFail::Error => return Err(self.unsatisfied(req)),
123 OnFail::Download => {}
124 }
125 }
126
127 progress.on_phase(None, crate::progress::InstallPhase::Resolving);
129 let platform = Platform::current()?;
130 let (version, fresh_pin) = match pinned {
131 Some(p) => (p.version.clone(), None),
132 None => {
133 let selected = match index::load_index(&self.http, &self.cfg).await {
134 Ok(entries) => index::select(&entries, &target, &platform)
135 .map(|e| e.version.clone())
136 .ok_or_else(|| Error::NoMatchingVersion {
137 requested: req.raw.clone(),
138 platform_note: format!(" with a build for {}", platform.label()),
139 }),
140 Err(e) => Err(e),
141 };
142 match selected {
143 Ok(v) => (v, None),
144 Err(_) if req.on_fail == OnFail::Ignore => return Ok(None),
147 Err(e) if req.on_fail == OnFail::Warn => {
148 tracing::warn!(
149 code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
150 requested = %req.raw,
151 source = req.source.label(),
152 error = %e,
153 "could not verify the project's runtime requirement; continuing on the active Node.js"
154 );
155 return Ok(None);
156 }
157 Err(e) => return Err(e),
158 }
159 }
160 };
161
162 let exact = NodeSpec::Exact(version.clone());
166 if let Some(resolution) = local_resolution(&exact) {
167 return Ok(Some(resolution));
168 }
169 match req.on_fail {
173 OnFail::Ignore => return Ok(None),
174 OnFail::Warn => {
175 warn_version_mismatch(req);
176 return Ok(None);
177 }
178 OnFail::Error => return Err(self.unsatisfied(req)),
179 OnFail::Download => {}
180 }
181
182 let artifact_base = self.cfg.artifact_base(&platform);
185 let pinned_variant = pinned
186 .and_then(|p| p.variant_for(&platform.os, &platform.cpu, platform.libc.as_deref()));
187 let (download, fresh_pin) = match pinned_variant {
188 Some(v) => {
189 let expected =
190 sha256_from_sri(&v.integrity_sri).ok_or_else(|| Error::ChecksumMismatch {
191 url: v.url.clone(),
192 expected: v.integrity_sri.clone(),
193 actual: "<unparseable lockfile integrity>".to_string(),
194 })?;
195 (
196 DownloadSpec {
197 url: v.url.clone(),
198 expected_sha256: expected,
199 zip: v.archive == "zip",
200 },
201 fresh_pin,
202 )
203 }
204 None => {
205 if pinned.is_some() {
206 tracing::warn!(
210 version = %version,
211 platform = %platform.label(),
212 "lockfile runtime pin has no variant for this platform; using live checksums"
213 );
214 }
215 let sums =
216 shasums::load_shasums(&self.http, &self.cfg, &artifact_base, &version).await?;
217 let filename = artifact_filename(&version, &platform);
218 let digest = sums.for_file(&filename).copied().ok_or_else(|| {
219 Error::UnsupportedPlatform {
220 platform: platform.label(),
221 }
222 })?;
223 let pin = self.build_full_pin(&version).await.unwrap_or_else(|e| {
224 tracing::debug!(error = %e, "could not build full runtime pin");
225 PinnedNode {
226 version: version.clone(),
227 variants: Vec::new(),
228 }
229 });
230 (
231 DownloadSpec {
232 url: format!("{artifact_base}/v{version}/{filename}"),
233 expected_sha256: digest,
234 zip: platform.os == "win32",
235 },
236 Some(pin),
237 )
238 }
239 };
240
241 let installed = self.install(&version, &download, progress).await?;
243 Ok(Some(Resolution {
244 version: installed.version.clone(),
245 bin_dir: Some(installed.bin_dir.clone()),
246 node_bin: installed.node_bin.clone(),
247 from: ResolvedFrom::FreshInstall(installed.origin),
248 fresh_pin,
249 }))
250 }
251
252 async fn install(
253 &self,
254 version: &node_semver::Version,
255 download: &DownloadSpec,
256 progress: &dyn DownloadProgress,
257 ) -> Result<InstalledNode, Error> {
258 match self.cfg.installer {
259 InstallerMode::Aube => {
260 installer::install(&self.http, version, download, progress).await
261 }
262 InstallerMode::Mise => {
263 let Some(mise_bin) = mise::mise_on_path() else {
264 return Err(Error::MiseInstallFailed {
265 version: format!("node@{version}"),
266 reason: "runtimeInstaller=mise but mise is not on PATH".to_string(),
267 });
268 };
269 mise::install_via_mise(&mise_bin, version, progress).await
270 }
271 InstallerMode::Auto => match mise::mise_on_path() {
272 Some(mise_bin) => {
273 match mise::install_via_mise(&mise_bin, version, progress).await {
274 Ok(node) => Ok(node),
275 Err(e) => {
276 tracing::warn!(
277 code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK,
278 error = %e,
279 "mise failed to install the runtime; falling back to aube's own download"
280 );
281 installer::install(&self.http, version, download, progress).await
282 }
283 }
284 }
285 None => installer::install(&self.http, version, download, progress).await,
286 },
287 }
288 }
289
290 fn unsatisfied(&self, req: &NodeRequest) -> Error {
291 let current = discover::probe_path_node()
292 .map(|(v, _)| format!(" (PATH provides {v})"))
293 .unwrap_or_else(|| " (no node on PATH)".to_string());
294 Error::VersionUnsatisfied {
295 requested: req.raw.clone(),
296 hint: format!(
297 "{current}; required by {} at {}",
298 req.source.label(),
299 req.origin.display()
300 ),
301 }
302 }
303
304 pub async fn resolve_for_lockfile(&self, spec: &NodeSpec) -> Result<PinnedNode, Error> {
308 let platform = Platform::current()?;
309 let entries = index::load_index(&self.http, &self.cfg).await?;
310 let entry =
311 index::select(&entries, spec, &platform).ok_or_else(|| Error::NoMatchingVersion {
312 requested: spec.display(),
313 platform_note: String::new(),
314 })?;
315 let version = entry.version.clone();
316 self.build_full_pin(&version).await
317 }
318
319 async fn build_full_pin(&self, version: &node_semver::Version) -> Result<PinnedNode, Error> {
324 let base = self.cfg.mirror_base();
325 let sums = shasums::load_shasums(&self.http, &self.cfg, &base, version).await?;
326 let mut variants = variants_from_shasums(&base, version, sums.iter());
327 if self.cfg.mirror.is_none() {
328 let musl_base = crate::UNOFFICIAL_BASE;
329 match shasums::load_shasums(&self.http, &self.cfg, musl_base, version).await {
330 Ok(musl_sums) => {
331 variants.extend(
332 variants_from_shasums(musl_base, version, musl_sums.iter())
333 .into_iter()
334 .filter(|v| v.libc.as_deref() == Some("musl")),
335 );
336 }
337 Err(e) => {
338 tracing::debug!(error = %e, "no musl builds recorded for v{version}");
339 }
340 }
341 }
342 Ok(PinnedNode {
343 version: version.clone(),
344 variants,
345 })
346 }
347}
348
349fn warn_version_mismatch(req: &NodeRequest) {
350 tracing::warn!(
351 code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH,
352 requested = %req.raw,
353 source = req.source.label(),
354 "the active Node.js does not satisfy the project's runtime requirement"
355 );
356}
357
358fn local_resolution(target: &NodeSpec) -> Option<Resolution> {
361 if let Some((version, node_bin)) = discover::probe_path_node()
362 && target.satisfied_by(&version) == Some(true)
363 {
364 return Some(Resolution {
365 version,
366 bin_dir: None,
367 node_bin,
368 from: ResolvedFrom::PathEnv,
369 fresh_pin: None,
370 });
371 }
372 let best = discover::list_installed()
373 .into_iter()
374 .filter(|n| target.satisfied_by(&n.version) == Some(true))
375 .max_by(|a, b| a.version.cmp(&b.version))?;
376 Some(Resolution {
377 version: best.version.clone(),
378 bin_dir: Some(best.bin_dir.clone()),
379 node_bin: best.node_bin.clone(),
380 from: ResolvedFrom::Installed(best.origin),
381 fresh_pin: None,
382 })
383}
384
385fn variants_from_shasums<'a>(
389 base: &str,
390 version: &node_semver::Version,
391 entries: impl Iterator<Item = (&'a String, &'a [u8; 32])>,
392) -> Vec<PinnedVariant> {
393 let prefix = format!("node-v{version}-");
394 let mut out = Vec::new();
395 for (filename, digest) in entries {
396 let Some(rest) = filename.strip_prefix(&prefix) else {
397 continue;
398 };
399 let (slug, ext) = if let Some(s) = rest.strip_suffix(".tar.gz") {
400 (s, "tar.gz")
401 } else if let Some(s) = rest.strip_suffix(".zip") {
402 (s, "zip")
403 } else {
404 continue;
405 };
406 let (slug, musl) = match slug.strip_suffix("-musl") {
407 Some(s) => (s, true),
408 None => (slug, false),
409 };
410 let Some((os_raw, cpu)) = slug.split_once('-') else {
411 continue;
412 };
413 if cpu.contains('-') {
417 continue;
418 }
419 let os = match os_raw {
420 "win" => "win32",
421 "osx" | "darwin" => "darwin",
422 "linux" => "linux",
423 "aix" => "aix",
424 _ => continue,
425 };
426 let bin: BTreeMap<String, String> = if os == "win32" {
427 [("node".to_string(), "node.exe".to_string())].into()
428 } else {
429 [("node".to_string(), "bin/node".to_string())].into()
430 };
431 out.push(PinnedVariant {
432 os: os.to_string(),
433 cpu: cpu.to_string(),
434 libc: musl.then(|| "musl".to_string()),
435 archive: if ext == "zip" { "zip" } else { "tarball" }.to_string(),
436 url: format!("{base}/v{version}/{filename}"),
437 integrity_sri: sri_sha256(digest),
438 bin,
439 prefix: (ext == "zip").then(|| {
440 let plat = Platform {
441 os: os.to_string(),
442 cpu: cpu.to_string(),
443 libc: musl.then(|| "musl".to_string()),
444 };
445 artifact_top_dir(version, &plat)
446 }),
447 });
448 }
449 out
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn shasums_variant_mapping() {
458 let version: node_semver::Version = "24.4.1".parse().unwrap();
459 let entries: Vec<(String, [u8; 32])> = vec![
460 ("node-v24.4.1-darwin-arm64.tar.gz".into(), [1; 32]),
461 ("node-v24.4.1-linux-x64.tar.gz".into(), [2; 32]),
462 ("node-v24.4.1-linux-x64-musl.tar.gz".into(), [3; 32]),
463 ("node-v24.4.1-win-x64.zip".into(), [4; 32]),
464 ("node-v24.4.1-headers.tar.gz".into(), [5; 32]),
465 ("node-v24.4.1.pkg".into(), [6; 32]),
466 ("node-v24.4.1-win-x64.7z".into(), [7; 32]),
467 ("node-v24.4.1-darwin-arm64.tar.xz".into(), [8; 32]),
468 ];
469 let variants = variants_from_shasums(
470 "https://nodejs.org/download/release",
471 &version,
472 entries.iter().map(|(k, v)| (k, v)),
473 );
474 let labels: Vec<String> = variants
475 .iter()
476 .map(|v| {
477 format!(
478 "{}-{}{}",
479 v.os,
480 v.cpu,
481 v.libc
482 .as_deref()
483 .map(|l| format!("-{l}"))
484 .unwrap_or_default()
485 )
486 })
487 .collect();
488 assert_eq!(
489 labels,
490 vec!["darwin-arm64", "linux-x64", "linux-x64-musl", "win32-x64"]
491 );
492 let win = variants.iter().find(|v| v.os == "win32").unwrap();
493 assert_eq!(win.archive, "zip");
494 assert_eq!(win.prefix.as_deref(), Some("node-v24.4.1-win-x64"));
495 assert_eq!(win.bin.get("node").map(String::as_str), Some("node.exe"));
496 assert!(win.url.ends_with("/v24.4.1/node-v24.4.1-win-x64.zip"));
497 let mac = variants.iter().find(|v| v.os == "darwin").unwrap();
498 assert_eq!(mac.archive, "tarball");
499 assert_eq!(mac.prefix, None);
500 assert!(mac.integrity_sri.starts_with("sha256-"));
501 }
502}