Skip to repository content1613 lines · 53.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:28:52.986Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
node_runtime.rs
1use anyhow::{Context as _, Result, anyhow, bail};
2use async_compression::futures::bufread::GzipDecoder;
3use async_tar::Archive;
4use chrono::{DateTime, Utc};
5use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};
6use http_client::{Host, HttpClient, Url};
7use log::Level;
8use semver::{Version, VersionReq};
9use serde::Deserialize;
10use smol::io::BufReader;
11use smol::{fs, lock::Mutex};
12use std::collections::HashMap;
13use std::fmt::Display;
14use std::{
15 env::{self, consts},
16 ffi::OsString,
17 io,
18 net::{IpAddr, Ipv4Addr},
19 path::{Path, PathBuf},
20 process::Output,
21 sync::Arc,
22};
23use util::ResultExt;
24use util::archive::extract_zip;
25
26const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS";
27
28#[derive(Clone, Debug, Default, Eq, PartialEq)]
29pub struct NodeBinaryOptions {
30 pub allow_path_lookup: bool,
31 pub allow_binary_download: bool,
32 pub use_paths: Option<(PathBuf, PathBuf)>,
33}
34
35/// Use this when you need to launch npm as a long-lived process (for example, an agent server),
36/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.
37#[derive(Clone, Debug)]
38pub struct NpmCommand {
39 pub path: PathBuf,
40 pub args: Vec<String>,
41 pub env: HashMap<String, String>,
42}
43
44pub enum VersionStrategy<'a> {
45 /// Install if current version doesn't match pinned version
46 Pin(&'a Version),
47 /// Install if current version is older than latest version
48 Latest(&'a Version),
49}
50
51#[derive(Clone)]
52pub struct NodeRuntime(Arc<Mutex<NodeRuntimeState>>);
53
54struct NodeRuntimeState {
55 http: Arc<dyn HttpClient>,
56 instance: Option<Box<dyn NodeRuntimeTrait>>,
57 last_options: Option<NodeBinaryOptions>,
58 options: watch::Receiver<Option<NodeBinaryOptions>>,
59 shell_env_loaded: Shared<oneshot::Receiver<()>>,
60}
61
62impl NodeRuntime {
63 pub fn new(
64 http: Arc<dyn HttpClient>,
65 shell_env_loaded: Option<oneshot::Receiver<()>>,
66 options: watch::Receiver<Option<NodeBinaryOptions>>,
67 ) -> Self {
68 NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
69 http,
70 instance: None,
71 last_options: None,
72 options,
73 shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(),
74 })))
75 }
76
77 pub fn unavailable() -> Self {
78 NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
79 http: Arc::new(http_client::BlockedHttpClient),
80 instance: None,
81 last_options: None,
82 options: watch::channel(Some(NodeBinaryOptions::default())).1,
83 shell_env_loaded: oneshot::channel().1.shared(),
84 })))
85 }
86
87 async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {
88 let mut state = self.0.lock().await;
89
90 let options = loop {
91 if let Some(options) = state.options.borrow().as_ref() {
92 break options.clone();
93 }
94 match state.options.changed().await {
95 Ok(()) => {}
96 // failure case not cached
97 Err(err) => {
98 return Box::new(UnavailableNodeRuntime {
99 error_message: err.to_string().into(),
100 }) as Box<dyn NodeRuntimeTrait>;
101 }
102 }
103 };
104
105 if state.last_options.as_ref() != Some(&options) {
106 state.instance.take();
107 }
108 if let Some(instance) = state.instance.as_ref() {
109 return instance.boxed_clone();
110 }
111
112 if let Some((node, npm)) = options.use_paths.as_ref() {
113 let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await {
114 Ok(instance) => {
115 log::info!("using Node.js from `node.path` in settings: {:?}", instance);
116 Box::new(instance)
117 }
118 Err(err) => {
119 // failure case not cached, since it's cheap to check again
120 return Box::new(UnavailableNodeRuntime {
121 error_message: format!(
122 "failure checking Node.js from `node.path` in settings ({}): {:?}",
123 node.display(),
124 err
125 )
126 .into(),
127 });
128 }
129 };
130 state.instance = Some(instance.boxed_clone());
131 state.last_options = Some(options);
132 return instance;
133 }
134
135 let system_node_error = if options.allow_path_lookup {
136 state.shell_env_loaded.clone().await.ok();
137 match SystemNodeRuntime::detect().await {
138 Ok(instance) => {
139 log::info!("using Node.js found on PATH: {:?}", instance);
140 state.instance = Some(instance.boxed_clone());
141 state.last_options = Some(options);
142 return Box::new(instance) as Box<dyn NodeRuntimeTrait>;
143 }
144 Err(err) => Some(err),
145 }
146 } else {
147 None
148 };
149
150 let instance = if options.allow_binary_download {
151 let (log_level, why_using_managed) = match system_node_error {
152 Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()),
153 Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()),
154 None => (
155 Level::Info,
156 "`node.ignore_system_version` is `true` in settings".to_string(),
157 ),
158 };
159 match ManagedNodeRuntime::install_if_needed(&state.http).await {
160 Ok(instance) => {
161 log::log!(
162 log_level,
163 "using Omega managed Node.js at {} since {}",
164 instance.installation_path.display(),
165 why_using_managed
166 );
167 Box::new(instance) as Box<dyn NodeRuntimeTrait>
168 }
169 Err(err) => {
170 // failure case is cached, since downloading + installing may be expensive. The
171 // downside of this is that it may fail due to an intermittent network issue.
172 //
173 // TODO: Have `install_if_needed` indicate which failure cases are retryable
174 // and/or have shared tracking of when internet is available.
175 Box::new(UnavailableNodeRuntime {
176 error_message: format!(
177 "failure while downloading and/or installing Omega managed Node.js, \
178 restart Omega to retry: {}",
179 err
180 )
181 .into(),
182 }) as Box<dyn NodeRuntimeTrait>
183 }
184 }
185 } else if let Some(system_node_error) = system_node_error {
186 // failure case not cached, since it's cheap to check again
187 //
188 // TODO: When support is added for setting `options.allow_binary_download`, update this
189 // error message.
190 return Box::new(UnavailableNodeRuntime {
191 error_message: format!(
192 "failure while checking system Node.js from PATH: {}",
193 system_node_error
194 )
195 .into(),
196 });
197 } else {
198 // failure case is cached because it will always happen with these options
199 //
200 // TODO: When support is added for setting `options.allow_binary_download`, update this
201 // error message.
202 Box::new(UnavailableNodeRuntime {
203 error_message: "`node` settings do not allow any way to use Node.js"
204 .to_string()
205 .into(),
206 })
207 };
208
209 state.instance = Some(instance.boxed_clone());
210 state.last_options = Some(options);
211 instance
212 }
213
214 pub async fn binary_path(&self) -> Result<PathBuf> {
215 self.instance().await.binary_path()
216 }
217
218 pub async fn run_npm_subcommand(
219 &self,
220 directory: Option<&Path>,
221 subcommand: &str,
222 args: &[&str],
223 ) -> Result<Output> {
224 let http = self.0.lock().await.http.clone();
225 self.instance()
226 .await
227 .run_npm_subcommand(directory, http.proxy(), subcommand, args)
228 .await
229 }
230
231 pub async fn npm_package_installed_version(
232 &self,
233 local_package_directory: &Path,
234 name: &str,
235 ) -> Result<Option<Version>> {
236 self.instance()
237 .await
238 .npm_package_installed_version(local_package_directory, name)
239 .await
240 }
241
242 pub async fn npm_command(
243 &self,
244 prefix_dir: Option<&Path>,
245 subcommand: &str,
246 args: &[&str],
247 ) -> Result<NpmCommand> {
248 let http = self.0.lock().await.http.clone();
249 self.instance()
250 .await
251 .npm_command(prefix_dir, http.proxy(), subcommand, args)
252 .await
253 }
254
255 pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
256 self.npm_package_latest_version_with_requirement(name, None)
257 .await
258 }
259
260 pub async fn npm_package_latest_version_with_requirement(
261 &self,
262 name: &str,
263 version_requirement: Option<&VersionReq>,
264 ) -> Result<Version> {
265 let http = self.0.lock().await.http.clone();
266 let instance = self.instance().await;
267 let output = instance
268 .run_npm_subcommand(
269 None,
270 http.proxy(),
271 "info",
272 &[
273 name,
274 "--json",
275 "--fetch-retry-mintimeout",
276 "2000",
277 "--fetch-retry-maxtimeout",
278 "5000",
279 "--fetch-timeout",
280 "5000",
281 ],
282 )
283 .await?;
284
285 let info: NpmInfo = deserialize_npm_info_from_response(&output.stdout).map_err(|e| {
286 anyhow::anyhow!(
287 "failed to parse npm info response: {e}\nstdout: {}",
288 String::from_utf8_lossy(&output.stdout)
289 )
290 })?;
291 let before = npm_config_before(instance.as_ref(), http.proxy())
292 .await
293 .context("getting npm before config")
294 .log_err()
295 .flatten();
296 let latest_dist_tag = info.dist_tags.latest.clone();
297 let selected_version =
298 select_npm_package_version(name, info, before.as_deref(), version_requirement)?;
299 log::debug!(
300 "selected latest npm package version package={name:?} version_requirement={version_requirement:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}"
301 );
302 Ok(selected_version)
303 }
304
305 pub async fn npm_install_packages(
306 &self,
307 directory: &Path,
308 packages: &[(&str, &str)],
309 ) -> Result<()> {
310 if packages.is_empty() {
311 return Ok(());
312 }
313
314 log::debug!(
315 "installing npm packages directory={} packages={packages:?}",
316 directory.display()
317 );
318
319 let packages: Vec<_> = packages
320 .iter()
321 .map(|(name, version)| format!("{name}@{version}"))
322 .collect();
323
324 let arguments: Vec<_> = packages
325 .iter()
326 .map(|p| p.as_str())
327 .chain([
328 "--save-exact",
329 "--fetch-retry-mintimeout",
330 "2000",
331 "--fetch-retry-maxtimeout",
332 "5000",
333 "--fetch-timeout",
334 "5000",
335 ])
336 .collect();
337
338 // This is also wrong because the directory is wrong.
339 self.run_npm_subcommand(Some(directory), "install", &arguments)
340 .await?;
341 Ok(())
342 }
343
344 pub async fn npm_install_latest_packages(
345 &self,
346 directory: &Path,
347 package_names: &[&str],
348 ) -> Result<()> {
349 // Let npm apply user config such as `before` and `min-release-age` during resolution.
350 log::debug!(
351 "installing latest npm packages directory={} packages={package_names:?}",
352 directory.display()
353 );
354 let packages = package_names
355 .iter()
356 .map(|package_name| (*package_name, "latest"))
357 .collect::<Vec<_>>();
358 self.npm_install_packages(directory, &packages).await
359 }
360
361 pub async fn should_install_npm_package(
362 &self,
363 package_name: &str,
364 local_executable_path: &Path,
365 local_package_directory: &Path,
366 version_strategy: VersionStrategy<'_>,
367 ) -> bool {
368 // In the case of the local system not having the package installed,
369 // or in the instances where we fail to parse package.json data,
370 // we attempt to install the package.
371 if fs::metadata(local_executable_path).await.is_err() {
372 log::debug!(
373 "npm package cache miss package={package_name:?} reason=missing-executable executable={}",
374 local_executable_path.display()
375 );
376 return true;
377 }
378
379 let Some(installed_version) = self
380 .npm_package_installed_version(local_package_directory, package_name)
381 .await
382 .log_err()
383 .flatten()
384 else {
385 log::debug!(
386 "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}",
387 local_package_directory.display()
388 );
389 return true;
390 };
391
392 let version_strategy_label = match &version_strategy {
393 VersionStrategy::Pin(version) => format!("pin:{version}"),
394 VersionStrategy::Latest(version) => format!("latest:{version}"),
395 };
396 let should_install =
397 should_install_npm_package_version(&installed_version, version_strategy);
398 log::debug!(
399 "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}"
400 );
401 should_install
402 }
403}
404
405fn should_install_npm_package_version(
406 installed_version: &Version,
407 version_strategy: VersionStrategy<'_>,
408) -> bool {
409 match version_strategy {
410 VersionStrategy::Pin(pinned_version) => installed_version != pinned_version,
411 VersionStrategy::Latest(latest_version) => installed_version < latest_version,
412 }
413}
414
415enum ArchiveType {
416 TarGz,
417 Zip,
418}
419
420#[derive(Debug, Deserialize)]
421#[serde(rename_all = "kebab-case")]
422pub struct NpmInfo {
423 #[serde(default)]
424 dist_tags: NpmInfoDistTags,
425 versions: Vec<Version>,
426 #[serde(default, deserialize_with = "deserialize_npm_info_time")]
427 time: HashMap<String, String>,
428}
429
430/// Parse NpmInfo from npm info --json output, handling both v11 and >= v12 formats.
431fn deserialize_npm_info_from_response(data: &[u8]) -> Result<NpmInfo, serde_json::Error> {
432 let value: serde_json::Value = serde_json::from_slice(data)?;
433
434 // npm >= 12 returns an array with one object: [ { ... } ]
435 if let serde_json::Value::Array(arr) = &value {
436 if arr.len() == 1 {
437 return NpmInfo::deserialize(&arr[0]);
438 }
439 }
440
441 // npm <= v11 returns a bare JSON object: { ... }
442 NpmInfo::deserialize(value)
443}
444
445#[derive(Debug, Deserialize, Default)]
446pub struct NpmInfoDistTags {
447 latest: Option<Version>,
448}
449
450// Some registries put non-string values in the `time` map: JFrog Artifactory emits
451// `"unpublished": null`, and npm itself reports `unpublished` as an object when a
452// package has had versions unpublished. Only version keys map to the RFC 3339 strings
453// we read, so keep the string entries and drop the rest rather than failing to parse
454// the entire `npm info` response (which would block language server installation).
455fn deserialize_npm_info_time<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
456where
457 D: serde::Deserializer<'de>,
458{
459 let entries = HashMap::<String, serde_json::Value>::deserialize(deserializer)?;
460 Ok(entries
461 .into_iter()
462 .filter_map(|(key, value)| match value {
463 serde_json::Value::String(value) => Some((key, value)),
464 _ => None,
465 })
466 .collect())
467}
468
469#[derive(Debug, Deserialize)]
470struct NpmConfig {
471 #[serde(default)]
472 before: Option<String>,
473}
474
475async fn npm_config_before(
476 node_runtime: &dyn NodeRuntimeTrait,
477 proxy: Option<&Url>,
478) -> Result<Option<String>> {
479 // `npm config get before` renders Date values for display. The JSON config output keeps the
480 // computed cutoff in the same ISO format used by `npm info --json` release times.
481 let output = node_runtime
482 .run_npm_subcommand(None, proxy, "config", &["list", "--json"])
483 .await?;
484 let config: NpmConfig = serde_json::from_slice(&output.stdout)?;
485 Ok(config
486 .before
487 .filter(|before| !before.trim().is_empty() && before != "null"))
488}
489
490fn select_npm_package_version(
491 package_name: &str,
492 mut info: NpmInfo,
493 before: Option<&str>,
494 version_requirement: Option<&VersionReq>,
495) -> Result<Version> {
496 if let Some(version_requirement) = version_requirement {
497 info.versions
498 .retain(|version| version_requirement.matches(version));
499 info.versions.sort();
500 info.dist_tags.latest = info
501 .dist_tags
502 .latest
503 .take()
504 .filter(|version| version_requirement.matches(version));
505 }
506
507 if let Some(before) = before
508 && !info.time.is_empty()
509 {
510 let before_timestamp = DateTime::parse_from_rfc3339(before)
511 .with_context(|| format!("parsing npm before config timestamp {before:?}"))?
512 .with_timezone(&Utc);
513 let latest_version = info.dist_tags.latest.as_ref();
514
515 if let Some(version) = latest_version
516 && npm_version_was_published_before(version, &info.time, &before_timestamp)?
517 {
518 return Ok(version.clone());
519 }
520
521 for version in info.versions.iter().rev() {
522 if is_allowed_npm_version_before(
523 version,
524 latest_version,
525 &info.time,
526 &before_timestamp,
527 version_requirement.is_some(),
528 )? {
529 return Ok(version.clone());
530 }
531 }
532
533 bail!("no version found for npm package {package_name} before {before}");
534 }
535
536 info.dist_tags
537 .latest
538 .or_else(|| info.versions.pop())
539 .with_context(|| format!("no version found for npm package {package_name}"))
540}
541
542fn is_allowed_npm_version_before(
543 version: &Version,
544 latest_version: Option<&Version>,
545 published_at_by_version: &HashMap<String, String>,
546 before: &DateTime<Utc>,
547 allow_prereleases: bool,
548) -> Result<bool> {
549 if (!allow_prereleases && !version.pre.is_empty())
550 || latest_version.is_some_and(|latest_version| version > latest_version)
551 {
552 return Ok(false);
553 }
554
555 npm_version_was_published_before(version, published_at_by_version, before)
556}
557
558fn npm_version_was_published_before(
559 version: &Version,
560 published_at_by_version: &HashMap<String, String>,
561 before: &DateTime<Utc>,
562) -> Result<bool> {
563 let Some(published_at) = published_at_by_version.get(&version.to_string()) else {
564 return Ok(false);
565 };
566 let published_at = DateTime::parse_from_rfc3339(published_at)
567 .with_context(|| format!("parsing npm release timestamp for version {version}"))?
568 .with_timezone(&Utc);
569 Ok(&published_at <= before)
570}
571
572#[async_trait::async_trait]
573trait NodeRuntimeTrait: Send + Sync {
574 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait>;
575 fn binary_path(&self) -> Result<PathBuf>;
576
577 async fn run_npm_subcommand(
578 &self,
579 directory: Option<&Path>,
580 proxy: Option<&Url>,
581 subcommand: &str,
582 args: &[&str],
583 ) -> Result<Output>;
584
585 async fn npm_command(
586 &self,
587 prefix_dir: Option<&Path>,
588 proxy: Option<&Url>,
589 subcommand: &str,
590 args: &[&str],
591 ) -> Result<NpmCommand>;
592
593 async fn npm_package_installed_version(
594 &self,
595 local_package_directory: &Path,
596 name: &str,
597 ) -> Result<Option<Version>>;
598}
599
600#[derive(Clone)]
601struct ManagedNodeRuntime {
602 installation_path: PathBuf,
603}
604
605impl ManagedNodeRuntime {
606 const VERSION: &str = "v24.11.0";
607
608 #[cfg(not(windows))]
609 const NODE_PATH: &str = "bin/node";
610 #[cfg(windows)]
611 const NODE_PATH: &str = "node.exe";
612
613 #[cfg(not(windows))]
614 const NPM_PATH: &str = "bin/npm";
615 #[cfg(windows)]
616 const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";
617
618 async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Self> {
619 log::info!("Node runtime install_if_needed");
620
621 let os = match consts::OS {
622 "macos" => "darwin",
623 "linux" => "linux",
624 "windows" => "win",
625 other => bail!("Running on unsupported os: {other}"),
626 };
627
628 let arch = match consts::ARCH {
629 "x86_64" => "x64",
630 "aarch64" => "arm64",
631 other => bail!("Running on unsupported architecture: {other}"),
632 };
633
634 let version = Self::VERSION;
635 let folder_name = format!("node-{version}-{os}-{arch}");
636 let node_containing_dir = paths::data_dir().join("node");
637 let node_dir = node_containing_dir.join(folder_name);
638 let node_binary = node_dir.join(Self::NODE_PATH);
639 let npm_file = node_dir.join(Self::NPM_PATH);
640 let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
641
642 let valid = if fs::metadata(&node_binary).await.is_ok() {
643 let result = util::command::new_command(&node_binary)
644 .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
645 .arg(npm_file)
646 .arg("--version")
647 .args(["--cache".into(), node_dir.join("cache")])
648 .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
649 .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
650 .output()
651 .await;
652 match result {
653 Ok(output) => {
654 if output.status.success() {
655 true
656 } else {
657 log::warn!(
658 "Omega managed Node.js binary at {} failed check with output: {:?}",
659 node_binary.display(),
660 output
661 );
662 false
663 }
664 }
665 Err(err) => {
666 log::warn!(
667 "Omega managed Node.js binary at {} failed check, so re-downloading it. \
668 Error: {}",
669 node_binary.display(),
670 err
671 );
672 false
673 }
674 }
675 } else {
676 false
677 };
678
679 if !valid {
680 _ = fs::remove_dir_all(&node_containing_dir).await;
681 fs::create_dir(&node_containing_dir)
682 .await
683 .context("error creating node containing dir")?;
684
685 let archive_type = match consts::OS {
686 "macos" | "linux" => ArchiveType::TarGz,
687 "windows" => ArchiveType::Zip,
688 other => bail!("Running on unsupported os: {other}"),
689 };
690
691 let version = Self::VERSION;
692 let file_name = format!(
693 "node-{version}-{os}-{arch}.{extension}",
694 extension = match archive_type {
695 ArchiveType::TarGz => "tar.gz",
696 ArchiveType::Zip => "zip",
697 }
698 );
699
700 let url = format!("https://nodejs.org/dist/{version}/{file_name}");
701 log::info!("Downloading Node.js binary from {url}");
702 let mut response = http
703 .get(&url, Default::default(), true)
704 .await
705 .context("error downloading Node binary tarball")?;
706 log::info!("Download of Node.js complete, extracting...");
707
708 let body = response.body_mut();
709 match archive_type {
710 ArchiveType::TarGz => {
711 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
712 let archive = Archive::new(decompressed_bytes);
713 archive.unpack(&node_containing_dir).await?;
714 }
715 ArchiveType::Zip => extract_zip(&node_containing_dir, body).await?,
716 }
717 log::info!("Extracted Node.js to {}", node_containing_dir.display())
718 }
719
720 // Note: Not in the `if !valid {}` so we can populate these for existing installations
721 _ = fs::create_dir(node_dir.join("cache")).await;
722 _ = fs::write(node_dir.join("blank_user_npmrc"), []).await;
723 _ = fs::write(node_dir.join("blank_global_npmrc"), []).await;
724
725 anyhow::Ok(ManagedNodeRuntime {
726 installation_path: node_dir,
727 })
728 }
729}
730
731fn path_with_node_binary_prepended(node_binary: &Path) -> Option<OsString> {
732 let existing_path = env::var_os("PATH");
733 let node_bin_dir = node_binary.parent().map(|dir| dir.as_os_str());
734 match (existing_path, node_bin_dir) {
735 (Some(existing_path), Some(node_bin_dir)) => {
736 if let Ok(joined) = env::join_paths(
737 [PathBuf::from(node_bin_dir)]
738 .into_iter()
739 .chain(env::split_paths(&existing_path)),
740 ) {
741 Some(joined)
742 } else {
743 Some(existing_path)
744 }
745 }
746 (Some(existing_path), None) => Some(existing_path),
747 (None, Some(node_bin_dir)) => Some(node_bin_dir.to_owned()),
748 _ => None,
749 }
750}
751
752#[async_trait::async_trait]
753impl NodeRuntimeTrait for ManagedNodeRuntime {
754 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
755 Box::new(self.clone())
756 }
757
758 fn binary_path(&self) -> Result<PathBuf> {
759 Ok(self.installation_path.join(Self::NODE_PATH))
760 }
761
762 async fn run_npm_subcommand(
763 &self,
764 directory: Option<&Path>,
765 proxy: Option<&Url>,
766 subcommand: &str,
767 args: &[&str],
768 ) -> Result<Output> {
769 let attempt = || async {
770 let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
771 let mut command = util::command::new_command(npm_command.path);
772 command.args(npm_command.args);
773 command.envs(npm_command.env);
774 if let Some(directory) = directory {
775 command.current_dir(directory);
776 }
777 command.output().await.map_err(|e| anyhow!("{e}"))
778 };
779
780 let mut output = attempt().await;
781 if output.is_err() {
782 output = attempt().await;
783 anyhow::ensure!(
784 output.is_ok(),
785 "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}",
786 output.err()
787 );
788 }
789
790 if let Ok(output) = &output {
791 anyhow::ensure!(
792 output.status.success(),
793 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
794 String::from_utf8_lossy(&output.stdout),
795 String::from_utf8_lossy(&output.stderr)
796 );
797 }
798
799 output.map_err(|e| anyhow!("{e}"))
800 }
801
802 async fn npm_command(
803 &self,
804 prefix_dir: Option<&Path>,
805 proxy: Option<&Url>,
806 subcommand: &str,
807 args: &[&str],
808 ) -> Result<NpmCommand> {
809 let node_binary = self.installation_path.join(Self::NODE_PATH);
810 let npm_file = self.installation_path.join(Self::NPM_PATH);
811
812 anyhow::ensure!(
813 smol::fs::metadata(&node_binary).await.is_ok(),
814 "missing node binary file"
815 );
816 anyhow::ensure!(
817 smol::fs::metadata(&npm_file).await.is_ok(),
818 "missing npm file"
819 );
820
821 let command_args = build_npm_command_args(
822 Some(&npm_file),
823 prefix_dir,
824 &self.installation_path.join("cache"),
825 Some(&self.installation_path.join("blank_user_npmrc")),
826 Some(&self.installation_path.join("blank_global_npmrc")),
827 proxy,
828 subcommand,
829 args,
830 );
831 let command_env = npm_command_env(Some(&node_binary));
832
833 Ok(NpmCommand {
834 path: node_binary,
835 args: command_args,
836 env: command_env,
837 })
838 }
839
840 async fn npm_package_installed_version(
841 &self,
842 local_package_directory: &Path,
843 name: &str,
844 ) -> Result<Option<Version>> {
845 read_package_installed_version(local_package_directory.join("node_modules"), name).await
846 }
847}
848
849#[derive(Debug, Clone)]
850pub struct SystemNodeRuntime {
851 node: PathBuf,
852 npm: PathBuf,
853 scratch_dir: PathBuf,
854}
855
856impl SystemNodeRuntime {
857 const MIN_VERSION: semver::Version = Version::new(22, 0, 0);
858 async fn new(node: PathBuf, npm: PathBuf) -> Result<Self> {
859 let output = util::command::new_command(&node)
860 .arg("--version")
861 .output()
862 .await
863 .with_context(|| format!("running node from {:?}", node))?;
864 if !output.status.success() {
865 anyhow::bail!(
866 "failed to run node --version. stdout: {}, stderr: {}",
867 String::from_utf8_lossy(&output.stdout),
868 String::from_utf8_lossy(&output.stderr),
869 );
870 }
871 let version_str = String::from_utf8_lossy(&output.stdout);
872 let version = semver::Version::parse(version_str.trim().trim_start_matches('v'))?;
873 if version < Self::MIN_VERSION {
874 anyhow::bail!(
875 "node at {} is too old. want: {}, got: {}",
876 node.to_string_lossy(),
877 Self::MIN_VERSION,
878 version
879 )
880 }
881
882 let scratch_dir = paths::data_dir().join("node");
883 fs::create_dir(&scratch_dir).await.ok();
884 fs::create_dir(scratch_dir.join("cache")).await.ok();
885
886 Ok(Self {
887 node,
888 npm,
889 scratch_dir,
890 })
891 }
892
893 async fn detect() -> std::result::Result<Self, DetectError> {
894 let node = which::which("node").map_err(DetectError::NotInPath)?;
895 let npm = which::which("npm").map_err(DetectError::NotInPath)?;
896 Self::new(node, npm).await.map_err(DetectError::Other)
897 }
898}
899
900enum DetectError {
901 NotInPath(which::Error),
902 Other(anyhow::Error),
903}
904
905impl Display for DetectError {
906 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
907 match self {
908 DetectError::NotInPath(err) => {
909 write!(f, "system Node.js wasn't found on PATH: {}", err)
910 }
911 DetectError::Other(err) => {
912 write!(f, "checking system Node.js failed with error: {}", err)
913 }
914 }
915 }
916}
917
918#[async_trait::async_trait]
919impl NodeRuntimeTrait for SystemNodeRuntime {
920 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
921 Box::new(self.clone())
922 }
923
924 fn binary_path(&self) -> Result<PathBuf> {
925 Ok(self.node.clone())
926 }
927
928 async fn run_npm_subcommand(
929 &self,
930 directory: Option<&Path>,
931 proxy: Option<&Url>,
932 subcommand: &str,
933 args: &[&str],
934 ) -> anyhow::Result<Output> {
935 let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
936 let mut command = util::command::new_command(npm_command.path);
937 command.args(npm_command.args);
938 command.envs(npm_command.env);
939 if let Some(directory) = directory {
940 command.current_dir(directory);
941 }
942 let output = command.output().await?;
943 anyhow::ensure!(
944 output.status.success(),
945 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
946 String::from_utf8_lossy(&output.stdout),
947 String::from_utf8_lossy(&output.stderr)
948 );
949 Ok(output)
950 }
951
952 async fn npm_command(
953 &self,
954 prefix_dir: Option<&Path>,
955 proxy: Option<&Url>,
956 subcommand: &str,
957 args: &[&str],
958 ) -> Result<NpmCommand> {
959 let command_args = build_npm_command_args(
960 None,
961 prefix_dir,
962 &self.scratch_dir.join("cache"),
963 None,
964 None,
965 proxy,
966 subcommand,
967 args,
968 );
969 let command_env = npm_command_env(Some(&self.node));
970
971 Ok(NpmCommand {
972 path: self.npm.clone(),
973 args: command_args,
974 env: command_env,
975 })
976 }
977
978 async fn npm_package_installed_version(
979 &self,
980 local_package_directory: &Path,
981 name: &str,
982 ) -> Result<Option<Version>> {
983 read_package_installed_version(local_package_directory.join("node_modules"), name).await
984 // todo: allow returning a globally installed version (requires callers not to hard-code the path)
985 }
986}
987
988pub async fn read_package_installed_version(
989 node_module_directory: PathBuf,
990 name: &str,
991) -> Result<Option<Version>> {
992 let package_json_path = node_module_directory.join(name).join("package.json");
993
994 let mut file = match fs::File::open(package_json_path).await {
995 Ok(file) => file,
996 Err(err) => {
997 if err.kind() == io::ErrorKind::NotFound {
998 return Ok(None);
999 }
1000
1001 Err(err)?
1002 }
1003 };
1004
1005 #[derive(Deserialize)]
1006 struct PackageJson {
1007 version: Version,
1008 }
1009
1010 let mut contents = String::new();
1011 file.read_to_string(&mut contents).await?;
1012 let package_json: PackageJson = serde_json::from_str(&contents)?;
1013 Ok(Some(package_json.version))
1014}
1015
1016#[derive(Clone)]
1017pub struct UnavailableNodeRuntime {
1018 error_message: Arc<String>,
1019}
1020
1021#[async_trait::async_trait]
1022impl NodeRuntimeTrait for UnavailableNodeRuntime {
1023 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
1024 Box::new(self.clone())
1025 }
1026 fn binary_path(&self) -> Result<PathBuf> {
1027 bail!("{}", self.error_message)
1028 }
1029
1030 async fn run_npm_subcommand(
1031 &self,
1032 _: Option<&Path>,
1033 _: Option<&Url>,
1034 _: &str,
1035 _: &[&str],
1036 ) -> anyhow::Result<Output> {
1037 bail!("{}", self.error_message)
1038 }
1039
1040 async fn npm_command(
1041 &self,
1042 _: Option<&Path>,
1043 _proxy: Option<&Url>,
1044 _subcommand: &str,
1045 _args: &[&str],
1046 ) -> Result<NpmCommand> {
1047 bail!("{}", self.error_message)
1048 }
1049
1050 async fn npm_package_installed_version(
1051 &self,
1052 _local_package_directory: &Path,
1053 _: &str,
1054 ) -> Result<Option<Version>> {
1055 bail!("{}", self.error_message)
1056 }
1057}
1058
1059fn proxy_argument(proxy: Option<&Url>) -> Option<String> {
1060 let mut proxy = proxy.cloned()?;
1061 // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
1062 // NodeRuntime without environment information can not parse `localhost`
1063 // correctly.
1064 // TODO: map to `[::1]` if we are using ipv6
1065 if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
1066 {
1067 // When localhost is a valid Host, so is `127.0.0.1`
1068 let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
1069 }
1070
1071 Some(proxy.as_str().to_string())
1072}
1073
1074fn build_npm_command_args(
1075 entrypoint: Option<&Path>,
1076 prefix_dir: Option<&Path>,
1077 cache_dir: &Path,
1078 user_config: Option<&Path>,
1079 global_config: Option<&Path>,
1080 proxy: Option<&Url>,
1081 subcommand: &str,
1082 args: &[&str],
1083) -> Vec<String> {
1084 let mut command_args = Vec::new();
1085 if let Some(entrypoint) = entrypoint {
1086 command_args.push(entrypoint.to_string_lossy().into_owned());
1087 }
1088 if let Some(prefix_dir) = prefix_dir {
1089 command_args.push("--prefix".into());
1090 command_args.push(prefix_dir.to_string_lossy().into_owned());
1091 }
1092 command_args.push(subcommand.to_string());
1093 command_args.push(format!("--cache={}", cache_dir.display()));
1094 if let Some(user_config) = user_config {
1095 command_args.push("--userconfig".into());
1096 command_args.push(user_config.to_string_lossy().into_owned());
1097 }
1098 if let Some(global_config) = global_config {
1099 command_args.push("--globalconfig".into());
1100 command_args.push(global_config.to_string_lossy().into_owned());
1101 }
1102 if let Some(proxy_arg) = proxy_argument(proxy) {
1103 command_args.push("--proxy".into());
1104 command_args.push(proxy_arg);
1105 }
1106 command_args.extend(args.into_iter().map(|a| a.to_string()));
1107 command_args
1108}
1109
1110fn npm_command_env(node_binary: Option<&Path>) -> HashMap<String, String> {
1111 let mut command_env = HashMap::new();
1112 if let Some(node_binary) = node_binary {
1113 let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default();
1114 command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned());
1115 }
1116
1117 if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) {
1118 if !node_ca_certs.is_empty() {
1119 command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs);
1120 }
1121 }
1122
1123 #[cfg(windows)]
1124 {
1125 if let Some(val) = env::var("SYSTEMROOT")
1126 .context("Missing environment variable: SYSTEMROOT!")
1127 .log_err()
1128 {
1129 command_env.insert("SYSTEMROOT".into(), val);
1130 }
1131 if let Some(val) = env::var("ComSpec")
1132 .context("Missing environment variable: ComSpec!")
1133 .log_err()
1134 {
1135 command_env.insert("ComSpec".into(), val);
1136 }
1137 }
1138
1139 command_env
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use std::path::Path;
1145
1146 use anyhow::{Result, bail};
1147 use http_client::Url;
1148 use semver::{Version, VersionReq};
1149
1150 use super::{
1151 NpmInfo, VersionStrategy, build_npm_command_args, deserialize_npm_info_from_response,
1152 proxy_argument, select_npm_package_version, should_install_npm_package_version,
1153 };
1154
1155 // Map localhost to 127.0.0.1
1156 // NodeRuntime without environment information can not parse `localhost` correctly.
1157 #[test]
1158 fn test_proxy_argument_map_localhost_proxy() {
1159 const CASES: [(&str, &str); 4] = [
1160 // Map localhost to 127.0.0.1
1161 ("http://localhost:9090/", "http://127.0.0.1:9090/"),
1162 ("https://google.com/", "https://google.com/"),
1163 (
1164 "http://username:password@proxy.thing.com:8080/",
1165 "http://username:password@proxy.thing.com:8080/",
1166 ),
1167 // Test when localhost is contained within a different part of the URL
1168 (
1169 "http://username:localhost@localhost:8080/",
1170 "http://username:localhost@127.0.0.1:8080/",
1171 ),
1172 ];
1173
1174 for (proxy, mapped_proxy) in CASES {
1175 let proxy = Url::parse(proxy).unwrap();
1176 let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly");
1177 assert_eq!(
1178 proxy, mapped_proxy,
1179 "Incorrectly mapped localhost to 127.0.0.1"
1180 );
1181 }
1182 }
1183
1184 #[test]
1185 fn test_build_npm_command_args_inserts_prefix_before_subcommand() {
1186 let args = build_npm_command_args(
1187 None,
1188 Some(Path::new("/tmp/zed-prefix")),
1189 Path::new("/tmp/cache"),
1190 None,
1191 None,
1192 None,
1193 "exec",
1194 &["--yes", "--", "agent-package"],
1195 );
1196
1197 assert_eq!(
1198 args,
1199 vec![
1200 "--prefix".to_string(),
1201 "/tmp/zed-prefix".to_string(),
1202 "exec".to_string(),
1203 "--cache=/tmp/cache".to_string(),
1204 "--yes".to_string(),
1205 "--".to_string(),
1206 "agent-package".to_string(),
1207 ]
1208 );
1209 }
1210
1211 #[test]
1212 fn test_build_npm_command_args_keeps_entrypoint_before_prefix() {
1213 let args = build_npm_command_args(
1214 Some(Path::new("/tmp/npm-cli.js")),
1215 Some(Path::new("/tmp/zed-prefix")),
1216 Path::new("/tmp/cache"),
1217 None,
1218 None,
1219 None,
1220 "exec",
1221 &["--yes"],
1222 );
1223
1224 assert_eq!(
1225 args,
1226 vec![
1227 "/tmp/npm-cli.js".to_string(),
1228 "--prefix".to_string(),
1229 "/tmp/zed-prefix".to_string(),
1230 "exec".to_string(),
1231 "--cache=/tmp/cache".to_string(),
1232 "--yes".to_string(),
1233 ]
1234 );
1235 }
1236
1237 #[test]
1238 fn test_latest_version_strategy_accepts_newer_installed_versions() -> Result<()> {
1239 let target_version = Version::parse("2.0.0")?;
1240
1241 assert!(!should_install_npm_package_version(
1242 &Version::parse("2.0.0")?,
1243 VersionStrategy::Latest(&target_version)
1244 ));
1245 assert!(should_install_npm_package_version(
1246 &Version::parse("1.0.0")?,
1247 VersionStrategy::Latest(&target_version)
1248 ));
1249 assert!(!should_install_npm_package_version(
1250 &Version::parse("3.0.0")?,
1251 VersionStrategy::Latest(&target_version)
1252 ));
1253
1254 Ok(())
1255 }
1256
1257 #[test]
1258 fn test_select_npm_package_version_uses_dist_tag_without_before() -> Result<()> {
1259 let info: NpmInfo = serde_json::from_str(
1260 r#"{
1261 "dist-tags": { "latest": "3.0.0" },
1262 "versions": ["1.0.0", "2.0.0", "3.0.0"],
1263 "time": {
1264 "1.0.0": "2024-01-01T00:00:00.000Z",
1265 "2.0.0": "2024-02-01T00:00:00.000Z",
1266 "3.0.0": "2024-03-01T00:00:00.000Z"
1267 }
1268 }"#,
1269 )?;
1270
1271 assert_eq!(
1272 select_npm_package_version("test-package", info, None, None)?,
1273 Version::parse("3.0.0")?
1274 );
1275 Ok(())
1276 }
1277
1278 #[test]
1279 fn test_npm_info_skips_non_string_time_entries() -> Result<()> {
1280 // Registries such as JFrog Artifactory include `"unpublished": null` in `time`;
1281 // parsing must tolerate this rather than rejecting the whole response.
1282 let info: NpmInfo = serde_json::from_str(
1283 r#"{
1284 "dist-tags": { "latest": "2.0.0" },
1285 "versions": ["1.0.0", "2.0.0"],
1286 "time": {
1287 "unpublished": null,
1288 "created": "2024-01-01T00:00:00.000Z",
1289 "modified": "2024-02-01T00:00:00.000Z",
1290 "1.0.0": "2024-01-01T00:00:00.000Z",
1291 "2.0.0": "2024-02-01T00:00:00.000Z"
1292 }
1293 }"#,
1294 )?;
1295
1296 assert_eq!(
1297 select_npm_package_version(
1298 "test-package",
1299 info,
1300 Some("2024-02-15T00:00:00.000Z"),
1301 None
1302 )?,
1303 Version::parse("2.0.0")?
1304 );
1305 Ok(())
1306 }
1307
1308 #[test]
1309 fn test_select_npm_package_version_uses_latest_before_npm_before_config() -> Result<()> {
1310 let info: NpmInfo = serde_json::from_str(
1311 r#"{
1312 "dist-tags": { "latest": "3.0.0" },
1313 "versions": ["1.0.0", "2.0.0", "3.0.0"],
1314 "time": {
1315 "1.0.0": "2024-01-01T00:00:00.000Z",
1316 "2.0.0": "2024-02-01T00:00:00.000Z",
1317 "3.0.0": "2024-03-01T00:00:00.000Z"
1318 }
1319 }"#,
1320 )?;
1321
1322 assert_eq!(
1323 select_npm_package_version(
1324 "test-package",
1325 info,
1326 Some("2024-02-15T00:00:00.000Z"),
1327 None
1328 )?,
1329 Version::parse("2.0.0")?
1330 );
1331 Ok(())
1332 }
1333
1334 #[test]
1335 fn test_select_npm_package_version_keeps_allowed_latest_dist_tag() -> Result<()> {
1336 let info: NpmInfo = serde_json::from_str(
1337 r#"{
1338 "dist-tags": { "latest": "2.0.0" },
1339 "versions": ["1.0.0", "2.0.0", "3.0.0"],
1340 "time": {
1341 "1.0.0": "2024-01-01T00:00:00.000Z",
1342 "2.0.0": "2024-02-01T00:00:00.000Z",
1343 "3.0.0": "2024-03-01T00:00:00.000Z"
1344 }
1345 }"#,
1346 )?;
1347
1348 assert_eq!(
1349 select_npm_package_version(
1350 "test-package",
1351 info,
1352 Some("2024-02-15T00:00:00.000Z"),
1353 None
1354 )?,
1355 Version::parse("2.0.0")?
1356 );
1357 Ok(())
1358 }
1359
1360 #[test]
1361 fn test_select_npm_package_version_keeps_allowed_prerelease_latest_dist_tag() -> Result<()> {
1362 let info: NpmInfo = serde_json::from_str(
1363 r#"{
1364 "dist-tags": { "latest": "2.0.0-beta.1" },
1365 "versions": ["1.0.0", "2.0.0-beta.1"],
1366 "time": {
1367 "1.0.0": "2024-01-01T00:00:00.000Z",
1368 "2.0.0-beta.1": "2024-02-01T00:00:00.000Z"
1369 }
1370 }"#,
1371 )?;
1372
1373 assert_eq!(
1374 select_npm_package_version(
1375 "test-package",
1376 info,
1377 Some("2024-02-15T00:00:00.000Z"),
1378 None
1379 )?,
1380 Version::parse("2.0.0-beta.1")?
1381 );
1382 Ok(())
1383 }
1384
1385 #[test]
1386 fn test_select_npm_package_version_ignores_prereleases_before_cutoff() -> Result<()> {
1387 let info: NpmInfo = serde_json::from_str(
1388 r#"{
1389 "dist-tags": { "latest": "2.0.0" },
1390 "versions": ["1.0.0", "2.0.0-beta.1", "2.0.0"],
1391 "time": {
1392 "1.0.0": "2024-01-01T00:00:00.000Z",
1393 "2.0.0-beta.1": "2024-02-01T00:00:00.000Z",
1394 "2.0.0": "2024-03-01T00:00:00.000Z"
1395 }
1396 }"#,
1397 )?;
1398
1399 assert_eq!(
1400 select_npm_package_version(
1401 "test-package",
1402 info,
1403 Some("2024-02-15T00:00:00.000Z"),
1404 None
1405 )?,
1406 Version::parse("1.0.0")?
1407 );
1408 Ok(())
1409 }
1410
1411 #[test]
1412 fn test_select_npm_package_version_ignores_versions_above_latest_dist_tag() -> Result<()> {
1413 let info: NpmInfo = serde_json::from_str(
1414 r#"{
1415 "dist-tags": { "latest": "2.0.0" },
1416 "versions": ["1.0.0", "2.0.0", "3.0.0"],
1417 "time": {
1418 "1.0.0": "2024-01-01T00:00:00.000Z",
1419 "2.0.0": "2024-03-01T00:00:00.000Z",
1420 "3.0.0": "2024-02-01T00:00:00.000Z"
1421 }
1422 }"#,
1423 )?;
1424
1425 assert_eq!(
1426 select_npm_package_version(
1427 "test-package",
1428 info,
1429 Some("2024-02-15T00:00:00.000Z"),
1430 None
1431 )?,
1432 Version::parse("1.0.0")?
1433 );
1434 Ok(())
1435 }
1436
1437 #[test]
1438 fn test_select_npm_package_version_errors_when_no_version_matches_before() -> Result<()> {
1439 let info: NpmInfo = serde_json::from_str(
1440 r#"{
1441 "dist-tags": { "latest": "2.0.0" },
1442 "versions": ["1.0.0", "2.0.0"],
1443 "time": {
1444 "1.0.0": "2024-01-01T00:00:00.000Z",
1445 "2.0.0": "2024-02-01T00:00:00.000Z"
1446 }
1447 }"#,
1448 )?;
1449
1450 let Err(error) = select_npm_package_version(
1451 "test-package",
1452 info,
1453 Some("2023-12-01T00:00:00.000Z"),
1454 None,
1455 ) else {
1456 bail!("expected cutoff to reject all package versions");
1457 };
1458 assert_eq!(
1459 error.to_string(),
1460 "no version found for npm package test-package before 2023-12-01T00:00:00.000Z"
1461 );
1462 Ok(())
1463 }
1464
1465 #[test]
1466 fn test_select_npm_package_version_selects_latest_matching_requirement() -> Result<()> {
1467 let info: NpmInfo = serde_json::from_str(
1468 r#"{
1469 "dist-tags": { "latest": "7.0.0" },
1470 "versions": ["6.0.3", "7.0.0", "5.9.3", "6.0.2"]
1471 }"#,
1472 )?;
1473 let version_requirement = VersionReq::parse("^6")?;
1474
1475 assert_eq!(
1476 select_npm_package_version("test-package", info, None, Some(&version_requirement))?,
1477 Version::parse("6.0.3")?
1478 );
1479 Ok(())
1480 }
1481
1482 #[test]
1483 fn test_select_npm_package_version_applies_before_to_matching_versions() -> Result<()> {
1484 let info: NpmInfo = serde_json::from_str(
1485 r#"{
1486 "dist-tags": { "latest": "7.0.0" },
1487 "versions": ["6.0.3", "7.0.0", "6.0.2"],
1488 "time": {
1489 "6.0.2": "2024-02-01T00:00:00.000Z",
1490 "6.0.3": "2024-03-01T00:00:00.000Z",
1491 "7.0.0": "2024-04-01T00:00:00.000Z"
1492 }
1493 }"#,
1494 )?;
1495 let version_requirement = VersionReq::parse("^6")?;
1496
1497 assert_eq!(
1498 select_npm_package_version(
1499 "test-package",
1500 info,
1501 Some("2024-02-15T00:00:00.000Z"),
1502 Some(&version_requirement),
1503 )?,
1504 Version::parse("6.0.2")?
1505 );
1506 Ok(())
1507 }
1508
1509 #[test]
1510 fn test_select_npm_package_version_allows_requested_prerelease_before_cutoff() -> Result<()> {
1511 let info: NpmInfo = serde_json::from_str(
1512 r#"{
1513 "dist-tags": { "latest": "7.0.0" },
1514 "versions": ["7.1.0-beta.1", "7.1.0-beta.2", "7.0.0"],
1515 "time": {
1516 "7.0.0": "2024-01-01T00:00:00.000Z",
1517 "7.1.0-beta.1": "2024-02-01T00:00:00.000Z",
1518 "7.1.0-beta.2": "2024-03-01T00:00:00.000Z"
1519 }
1520 }"#,
1521 )?;
1522 let version_requirement = VersionReq::parse(">=7.1.0-beta.1, <7.1.0")?;
1523
1524 assert_eq!(
1525 select_npm_package_version(
1526 "test-package",
1527 info,
1528 Some("2024-02-15T00:00:00.000Z"),
1529 Some(&version_requirement),
1530 )?,
1531 Version::parse("7.1.0-beta.1")?
1532 );
1533 Ok(())
1534 }
1535
1536 #[test]
1537 fn test_select_npm_package_version_errors_without_matching_version() -> Result<()> {
1538 let info: NpmInfo = serde_json::from_str(
1539 r#"{
1540 "dist-tags": { "latest": "7.0.0" },
1541 "versions": ["5.9.3", "7.0.0"]
1542 }"#,
1543 )?;
1544 let version_requirement = VersionReq::parse("^6")?;
1545
1546 let error =
1547 select_npm_package_version("test-package", info, None, Some(&version_requirement))
1548 .expect_err("expected version requirement to reject all package versions");
1549 assert_eq!(
1550 error.to_string(),
1551 "no version found for npm package test-package"
1552 );
1553 Ok(())
1554 }
1555
1556 #[test]
1557 fn test_pinned_version_strategy_replaces_different_installed_version() -> Result<()> {
1558 let pinned_version = Version::parse("6.0.3")?;
1559
1560 assert!(!should_install_npm_package_version(
1561 &pinned_version,
1562 VersionStrategy::Pin(&pinned_version)
1563 ));
1564 assert!(should_install_npm_package_version(
1565 &Version::parse("7.0.0")?,
1566 VersionStrategy::Pin(&pinned_version)
1567 ));
1568 Ok(())
1569 }
1570
1571 #[test]
1572 fn test_deserialize_npm_info_npm11_format() -> Result<()> {
1573 let json = r#"{
1574 "dist-tags": { "latest": "3.0.0" },
1575 "versions": ["1.0.0", "2.0.0", "3.0.0"]
1576 }"#;
1577
1578 let info = deserialize_npm_info_from_response(json.as_bytes())?;
1579 assert_eq!(info.dist_tags.latest, Some(Version::parse("3.0.0")?));
1580 assert_eq!(
1581 info.versions,
1582 vec![
1583 Version::parse("1.0.0")?,
1584 Version::parse("2.0.0")?,
1585 Version::parse("3.0.0")?
1586 ]
1587 );
1588 Ok(())
1589 }
1590
1591 #[test]
1592 fn test_deserialize_npm_v12_format() -> Result<()> {
1593 let json = r#"[
1594 {
1595 "dist-tags": { "latest": "3.0.0" },
1596 "versions": ["1.0.0", "2.0.0", "3.0.0"]
1597 }
1598 ]"#;
1599
1600 let info = deserialize_npm_info_from_response(json.as_bytes())?;
1601 assert_eq!(info.dist_tags.latest, Some(Version::parse("3.0.0")?));
1602 assert_eq!(
1603 info.versions,
1604 vec![
1605 Version::parse("1.0.0")?,
1606 Version::parse("2.0.0")?,
1607 Version::parse("3.0.0")?
1608 ]
1609 );
1610 Ok(())
1611 }
1612}
1613