Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:27:33.027Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

node_runtime.rs

1613 lines · 53.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.