node_runtime.rs

   1use anyhow::{Context as _, Result, anyhow, bail};
   2use async_compression::futures::bufread::GzipDecoder;
   3use async_tar::Archive;
   4use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};
   5use http_client::{Host, HttpClient, Url};
   6use log::Level;
   7use semver::Version;
   8use serde::Deserialize;
   9use smol::io::BufReader;
  10use smol::{fs, lock::Mutex};
  11use std::collections::HashMap;
  12use std::fmt::Display;
  13use std::{
  14    env::{self, consts},
  15    ffi::OsString,
  16    io,
  17    net::{IpAddr, Ipv4Addr},
  18    path::{Path, PathBuf},
  19    process::Output,
  20    sync::Arc,
  21};
  22use util::ResultExt;
  23use util::archive::extract_zip;
  24
  25const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS";
  26
  27#[derive(Clone, Debug, Default, Eq, PartialEq)]
  28pub struct NodeBinaryOptions {
  29    pub allow_path_lookup: bool,
  30    pub allow_binary_download: bool,
  31    pub use_paths: Option<(PathBuf, PathBuf)>,
  32}
  33
  34/// Use this when you need to launch npm as a long-lived process (for example, an agent server),
  35/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.
  36#[derive(Clone, Debug)]
  37pub struct NpmCommand {
  38    pub path: PathBuf,
  39    pub args: Vec<String>,
  40    pub env: HashMap<String, String>,
  41}
  42
  43pub enum VersionStrategy<'a> {
  44    /// Install if current version doesn't match pinned version
  45    Pin(&'a Version),
  46    /// Install if current version is older than latest version
  47    Latest(&'a Version),
  48}
  49
  50#[derive(Clone)]
  51pub struct NodeRuntime(Arc<Mutex<NodeRuntimeState>>);
  52
  53struct NodeRuntimeState {
  54    http: Arc<dyn HttpClient>,
  55    instance: Option<Box<dyn NodeRuntimeTrait>>,
  56    last_options: Option<NodeBinaryOptions>,
  57    options: watch::Receiver<Option<NodeBinaryOptions>>,
  58    shell_env_loaded: Shared<oneshot::Receiver<()>>,
  59}
  60
  61impl NodeRuntime {
  62    pub fn new(
  63        http: Arc<dyn HttpClient>,
  64        shell_env_loaded: Option<oneshot::Receiver<()>>,
  65        options: watch::Receiver<Option<NodeBinaryOptions>>,
  66    ) -> Self {
  67        NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
  68            http,
  69            instance: None,
  70            last_options: None,
  71            options,
  72            shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(),
  73        })))
  74    }
  75
  76    pub fn unavailable() -> Self {
  77        NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
  78            http: Arc::new(http_client::BlockedHttpClient),
  79            instance: None,
  80            last_options: None,
  81            options: watch::channel(Some(NodeBinaryOptions::default())).1,
  82            shell_env_loaded: oneshot::channel().1.shared(),
  83        })))
  84    }
  85
  86    async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {
  87        let mut state = self.0.lock().await;
  88
  89        let options = loop {
  90            if let Some(options) = state.options.borrow().as_ref() {
  91                break options.clone();
  92            }
  93            match state.options.changed().await {
  94                Ok(()) => {}
  95                // failure case not cached
  96                Err(err) => {
  97                    return Box::new(UnavailableNodeRuntime {
  98                        error_message: err.to_string().into(),
  99                    }) as Box<dyn NodeRuntimeTrait>;
 100                }
 101            }
 102        };
 103
 104        if state.last_options.as_ref() != Some(&options) {
 105            state.instance.take();
 106        }
 107        if let Some(instance) = state.instance.as_ref() {
 108            return instance.boxed_clone();
 109        }
 110
 111        if let Some((node, npm)) = options.use_paths.as_ref() {
 112            let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await {
 113                Ok(instance) => {
 114                    log::info!("using Node.js from `node.path` in settings: {:?}", instance);
 115                    Box::new(instance)
 116                }
 117                Err(err) => {
 118                    // failure case not cached, since it's cheap to check again
 119                    return Box::new(UnavailableNodeRuntime {
 120                        error_message: format!(
 121                            "failure checking Node.js from `node.path` in settings ({}): {:?}",
 122                            node.display(),
 123                            err
 124                        )
 125                        .into(),
 126                    });
 127                }
 128            };
 129            state.instance = Some(instance.boxed_clone());
 130            state.last_options = Some(options);
 131            return instance;
 132        }
 133
 134        let system_node_error = if options.allow_path_lookup {
 135            state.shell_env_loaded.clone().await.ok();
 136            match SystemNodeRuntime::detect().await {
 137                Ok(instance) => {
 138                    log::info!("using Node.js found on PATH: {:?}", instance);
 139                    state.instance = Some(instance.boxed_clone());
 140                    state.last_options = Some(options);
 141                    return Box::new(instance) as Box<dyn NodeRuntimeTrait>;
 142                }
 143                Err(err) => Some(err),
 144            }
 145        } else {
 146            None
 147        };
 148
 149        let instance = if options.allow_binary_download {
 150            let (log_level, why_using_managed) = match system_node_error {
 151                Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()),
 152                Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()),
 153                None => (
 154                    Level::Info,
 155                    "`node.ignore_system_version` is `true` in settings".to_string(),
 156                ),
 157            };
 158            match ManagedNodeRuntime::install_if_needed(&state.http).await {
 159                Ok(instance) => {
 160                    log::log!(
 161                        log_level,
 162                        "using Zed managed Node.js at {} since {}",
 163                        instance.installation_path.display(),
 164                        why_using_managed
 165                    );
 166                    Box::new(instance) as Box<dyn NodeRuntimeTrait>
 167                }
 168                Err(err) => {
 169                    // failure case is cached, since downloading + installing may be expensive. The
 170                    // downside of this is that it may fail due to an intermittent network issue.
 171                    //
 172                    // TODO: Have `install_if_needed` indicate which failure cases are retryable
 173                    // and/or have shared tracking of when internet is available.
 174                    Box::new(UnavailableNodeRuntime {
 175                        error_message: format!(
 176                            "failure while downloading and/or installing Zed managed Node.js, \
 177                            restart Zed to retry: {}",
 178                            err
 179                        )
 180                        .into(),
 181                    }) as Box<dyn NodeRuntimeTrait>
 182                }
 183            }
 184        } else if let Some(system_node_error) = system_node_error {
 185            // failure case not cached, since it's cheap to check again
 186            //
 187            // TODO: When support is added for setting `options.allow_binary_download`, update this
 188            // error message.
 189            return Box::new(UnavailableNodeRuntime {
 190                error_message: format!(
 191                    "failure while checking system Node.js from PATH: {}",
 192                    system_node_error
 193                )
 194                .into(),
 195            });
 196        } else {
 197            // failure case is cached because it will always happen with these options
 198            //
 199            // TODO: When support is added for setting `options.allow_binary_download`, update this
 200            // error message.
 201            Box::new(UnavailableNodeRuntime {
 202                error_message: "`node` settings do not allow any way to use Node.js"
 203                    .to_string()
 204                    .into(),
 205            })
 206        };
 207
 208        state.instance = Some(instance.boxed_clone());
 209        state.last_options = Some(options);
 210        instance
 211    }
 212
 213    pub async fn binary_path(&self) -> Result<PathBuf> {
 214        self.instance().await.binary_path()
 215    }
 216
 217    pub async fn run_npm_subcommand(
 218        &self,
 219        directory: Option<&Path>,
 220        subcommand: &str,
 221        args: &[&str],
 222    ) -> Result<Output> {
 223        let http = self.0.lock().await.http.clone();
 224        self.instance()
 225            .await
 226            .run_npm_subcommand(directory, http.proxy(), subcommand, args)
 227            .await
 228    }
 229
 230    pub async fn npm_package_installed_version(
 231        &self,
 232        local_package_directory: &Path,
 233        name: &str,
 234    ) -> Result<Option<Version>> {
 235        self.instance()
 236            .await
 237            .npm_package_installed_version(local_package_directory, name)
 238            .await
 239    }
 240
 241    pub async fn npm_command(
 242        &self,
 243        prefix_dir: Option<&Path>,
 244        subcommand: &str,
 245        args: &[&str],
 246    ) -> Result<NpmCommand> {
 247        let http = self.0.lock().await.http.clone();
 248        self.instance()
 249            .await
 250            .npm_command(prefix_dir, http.proxy(), subcommand, args)
 251            .await
 252    }
 253
 254    pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
 255        let http = self.0.lock().await.http.clone();
 256        let output = self
 257            .instance()
 258            .await
 259            .run_npm_subcommand(
 260                None,
 261                http.proxy(),
 262                "info",
 263                &[
 264                    name,
 265                    "--json",
 266                    "--fetch-retry-mintimeout",
 267                    "2000",
 268                    "--fetch-retry-maxtimeout",
 269                    "5000",
 270                    "--fetch-timeout",
 271                    "5000",
 272                ],
 273            )
 274            .await?;
 275
 276        let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?;
 277        info.dist_tags
 278            .latest
 279            .or_else(|| info.versions.pop())
 280            .with_context(|| format!("no version found for npm package {name}"))
 281    }
 282
 283    pub async fn npm_install_packages(
 284        &self,
 285        directory: &Path,
 286        packages: &[(&str, &str)],
 287    ) -> Result<()> {
 288        if packages.is_empty() {
 289            return Ok(());
 290        }
 291
 292        let packages: Vec<_> = packages
 293            .iter()
 294            .map(|(name, version)| format!("{name}@{version}"))
 295            .collect();
 296
 297        let arguments: Vec<_> = packages
 298            .iter()
 299            .map(|p| p.as_str())
 300            .chain([
 301                "--save-exact",
 302                "--fetch-retry-mintimeout",
 303                "2000",
 304                "--fetch-retry-maxtimeout",
 305                "5000",
 306                "--fetch-timeout",
 307                "5000",
 308            ])
 309            .collect();
 310
 311        // This is also wrong because the directory is wrong.
 312        self.run_npm_subcommand(Some(directory), "install", &arguments)
 313            .await?;
 314        Ok(())
 315    }
 316
 317    pub async fn should_install_npm_package(
 318        &self,
 319        package_name: &str,
 320        local_executable_path: &Path,
 321        local_package_directory: &Path,
 322        version_strategy: VersionStrategy<'_>,
 323    ) -> bool {
 324        // In the case of the local system not having the package installed,
 325        // or in the instances where we fail to parse package.json data,
 326        // we attempt to install the package.
 327        if fs::metadata(local_executable_path).await.is_err() {
 328            return true;
 329        }
 330
 331        let Some(installed_version) = self
 332            .npm_package_installed_version(local_package_directory, package_name)
 333            .await
 334            .log_err()
 335            .flatten()
 336        else {
 337            return true;
 338        };
 339
 340        match version_strategy {
 341            VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version,
 342            VersionStrategy::Latest(latest_version) => &installed_version < latest_version,
 343        }
 344    }
 345}
 346
 347enum ArchiveType {
 348    TarGz,
 349    Zip,
 350}
 351
 352#[derive(Debug, Deserialize)]
 353#[serde(rename_all = "kebab-case")]
 354pub struct NpmInfo {
 355    #[serde(default)]
 356    dist_tags: NpmInfoDistTags,
 357    versions: Vec<Version>,
 358}
 359
 360#[derive(Debug, Deserialize, Default)]
 361pub struct NpmInfoDistTags {
 362    latest: Option<Version>,
 363}
 364
 365#[async_trait::async_trait]
 366trait NodeRuntimeTrait: Send + Sync {
 367    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait>;
 368    fn binary_path(&self) -> Result<PathBuf>;
 369
 370    async fn run_npm_subcommand(
 371        &self,
 372        directory: Option<&Path>,
 373        proxy: Option<&Url>,
 374        subcommand: &str,
 375        args: &[&str],
 376    ) -> Result<Output>;
 377
 378    async fn npm_command(
 379        &self,
 380        prefix_dir: Option<&Path>,
 381        proxy: Option<&Url>,
 382        subcommand: &str,
 383        args: &[&str],
 384    ) -> Result<NpmCommand>;
 385
 386    async fn npm_package_installed_version(
 387        &self,
 388        local_package_directory: &Path,
 389        name: &str,
 390    ) -> Result<Option<Version>>;
 391}
 392
 393#[derive(Clone)]
 394struct ManagedNodeRuntime {
 395    installation_path: PathBuf,
 396}
 397
 398impl ManagedNodeRuntime {
 399    const VERSION: &str = "v24.11.0";
 400
 401    #[cfg(not(windows))]
 402    const NODE_PATH: &str = "bin/node";
 403    #[cfg(windows)]
 404    const NODE_PATH: &str = "node.exe";
 405
 406    #[cfg(not(windows))]
 407    const NPM_PATH: &str = "bin/npm";
 408    #[cfg(windows)]
 409    const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";
 410
 411    async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Self> {
 412        log::info!("Node runtime install_if_needed");
 413
 414        let os = match consts::OS {
 415            "macos" => "darwin",
 416            "linux" => "linux",
 417            "windows" => "win",
 418            other => bail!("Running on unsupported os: {other}"),
 419        };
 420
 421        let arch = match consts::ARCH {
 422            "x86_64" => "x64",
 423            "aarch64" => "arm64",
 424            other => bail!("Running on unsupported architecture: {other}"),
 425        };
 426
 427        let version = Self::VERSION;
 428        let folder_name = format!("node-{version}-{os}-{arch}");
 429        let node_containing_dir = paths::data_dir().join("node");
 430        let node_dir = node_containing_dir.join(folder_name);
 431        let node_binary = node_dir.join(Self::NODE_PATH);
 432        let npm_file = node_dir.join(Self::NPM_PATH);
 433        let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
 434
 435        let valid = if fs::metadata(&node_binary).await.is_ok() {
 436            let result = util::command::new_command(&node_binary)
 437                .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
 438                .arg(npm_file)
 439                .arg("--version")
 440                .args(["--cache".into(), node_dir.join("cache")])
 441                .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
 442                .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
 443                .output()
 444                .await;
 445            match result {
 446                Ok(output) => {
 447                    if output.status.success() {
 448                        true
 449                    } else {
 450                        log::warn!(
 451                            "Zed managed Node.js binary at {} failed check with output: {:?}",
 452                            node_binary.display(),
 453                            output
 454                        );
 455                        false
 456                    }
 457                }
 458                Err(err) => {
 459                    log::warn!(
 460                        "Zed managed Node.js binary at {} failed check, so re-downloading it. \
 461                        Error: {}",
 462                        node_binary.display(),
 463                        err
 464                    );
 465                    false
 466                }
 467            }
 468        } else {
 469            false
 470        };
 471
 472        if !valid {
 473            _ = fs::remove_dir_all(&node_containing_dir).await;
 474            fs::create_dir(&node_containing_dir)
 475                .await
 476                .context("error creating node containing dir")?;
 477
 478            let archive_type = match consts::OS {
 479                "macos" | "linux" => ArchiveType::TarGz,
 480                "windows" => ArchiveType::Zip,
 481                other => bail!("Running on unsupported os: {other}"),
 482            };
 483
 484            let version = Self::VERSION;
 485            let file_name = format!(
 486                "node-{version}-{os}-{arch}.{extension}",
 487                extension = match archive_type {
 488                    ArchiveType::TarGz => "tar.gz",
 489                    ArchiveType::Zip => "zip",
 490                }
 491            );
 492
 493            let url = format!("https://nodejs.org/dist/{version}/{file_name}");
 494            log::info!("Downloading Node.js binary from {url}");
 495            let mut response = http
 496                .get(&url, Default::default(), true)
 497                .await
 498                .context("error downloading Node binary tarball")?;
 499            log::info!("Download of Node.js complete, extracting...");
 500
 501            let body = response.body_mut();
 502            match archive_type {
 503                ArchiveType::TarGz => {
 504                    let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
 505                    let archive = Archive::new(decompressed_bytes);
 506                    archive.unpack(&node_containing_dir).await?;
 507                }
 508                ArchiveType::Zip => extract_zip(&node_containing_dir, body).await?,
 509            }
 510            log::info!("Extracted Node.js to {}", node_containing_dir.display())
 511        }
 512
 513        // Note: Not in the `if !valid {}` so we can populate these for existing installations
 514        _ = fs::create_dir(node_dir.join("cache")).await;
 515        _ = fs::write(node_dir.join("blank_user_npmrc"), []).await;
 516        _ = fs::write(node_dir.join("blank_global_npmrc"), []).await;
 517
 518        anyhow::Ok(ManagedNodeRuntime {
 519            installation_path: node_dir,
 520        })
 521    }
 522}
 523
 524fn path_with_node_binary_prepended(node_binary: &Path) -> Option<OsString> {
 525    let existing_path = env::var_os("PATH");
 526    let node_bin_dir = node_binary.parent().map(|dir| dir.as_os_str());
 527    match (existing_path, node_bin_dir) {
 528        (Some(existing_path), Some(node_bin_dir)) => {
 529            if let Ok(joined) = env::join_paths(
 530                [PathBuf::from(node_bin_dir)]
 531                    .into_iter()
 532                    .chain(env::split_paths(&existing_path)),
 533            ) {
 534                Some(joined)
 535            } else {
 536                Some(existing_path)
 537            }
 538        }
 539        (Some(existing_path), None) => Some(existing_path),
 540        (None, Some(node_bin_dir)) => Some(node_bin_dir.to_owned()),
 541        _ => None,
 542    }
 543}
 544
 545#[async_trait::async_trait]
 546impl NodeRuntimeTrait for ManagedNodeRuntime {
 547    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
 548        Box::new(self.clone())
 549    }
 550
 551    fn binary_path(&self) -> Result<PathBuf> {
 552        Ok(self.installation_path.join(Self::NODE_PATH))
 553    }
 554
 555    async fn run_npm_subcommand(
 556        &self,
 557        directory: Option<&Path>,
 558        proxy: Option<&Url>,
 559        subcommand: &str,
 560        args: &[&str],
 561    ) -> Result<Output> {
 562        let attempt = || async {
 563            let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
 564            let mut command = util::command::new_command(npm_command.path);
 565            command.args(npm_command.args);
 566            command.envs(npm_command.env);
 567            if let Some(directory) = directory {
 568                command.current_dir(directory);
 569            }
 570            command.output().await.map_err(|e| anyhow!("{e}"))
 571        };
 572
 573        let mut output = attempt().await;
 574        if output.is_err() {
 575            output = attempt().await;
 576            anyhow::ensure!(
 577                output.is_ok(),
 578                "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}",
 579                output.err()
 580            );
 581        }
 582
 583        if let Ok(output) = &output {
 584            anyhow::ensure!(
 585                output.status.success(),
 586                "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
 587                String::from_utf8_lossy(&output.stdout),
 588                String::from_utf8_lossy(&output.stderr)
 589            );
 590        }
 591
 592        output.map_err(|e| anyhow!("{e}"))
 593    }
 594
 595    async fn npm_command(
 596        &self,
 597        prefix_dir: Option<&Path>,
 598        proxy: Option<&Url>,
 599        subcommand: &str,
 600        args: &[&str],
 601    ) -> Result<NpmCommand> {
 602        let node_binary = self.installation_path.join(Self::NODE_PATH);
 603        let npm_file = self.installation_path.join(Self::NPM_PATH);
 604
 605        anyhow::ensure!(
 606            smol::fs::metadata(&node_binary).await.is_ok(),
 607            "missing node binary file"
 608        );
 609        anyhow::ensure!(
 610            smol::fs::metadata(&npm_file).await.is_ok(),
 611            "missing npm file"
 612        );
 613
 614        let command_args = build_npm_command_args(
 615            Some(&npm_file),
 616            prefix_dir,
 617            &self.installation_path.join("cache"),
 618            Some(&self.installation_path.join("blank_user_npmrc")),
 619            Some(&self.installation_path.join("blank_global_npmrc")),
 620            proxy,
 621            subcommand,
 622            args,
 623        );
 624        let command_env = npm_command_env(Some(&node_binary));
 625
 626        Ok(NpmCommand {
 627            path: node_binary,
 628            args: command_args,
 629            env: command_env,
 630        })
 631    }
 632
 633    async fn npm_package_installed_version(
 634        &self,
 635        local_package_directory: &Path,
 636        name: &str,
 637    ) -> Result<Option<Version>> {
 638        read_package_installed_version(local_package_directory.join("node_modules"), name).await
 639    }
 640}
 641
 642#[derive(Debug, Clone)]
 643pub struct SystemNodeRuntime {
 644    node: PathBuf,
 645    npm: PathBuf,
 646    global_node_modules: PathBuf,
 647    scratch_dir: PathBuf,
 648}
 649
 650impl SystemNodeRuntime {
 651    const MIN_VERSION: semver::Version = Version::new(22, 0, 0);
 652    async fn new(node: PathBuf, npm: PathBuf) -> Result<Self> {
 653        let output = util::command::new_command(&node)
 654            .arg("--version")
 655            .output()
 656            .await
 657            .with_context(|| format!("running node from {:?}", node))?;
 658        if !output.status.success() {
 659            anyhow::bail!(
 660                "failed to run node --version. stdout: {}, stderr: {}",
 661                String::from_utf8_lossy(&output.stdout),
 662                String::from_utf8_lossy(&output.stderr),
 663            );
 664        }
 665        let version_str = String::from_utf8_lossy(&output.stdout);
 666        let version = semver::Version::parse(version_str.trim().trim_start_matches('v'))?;
 667        if version < Self::MIN_VERSION {
 668            anyhow::bail!(
 669                "node at {} is too old. want: {}, got: {}",
 670                node.to_string_lossy(),
 671                Self::MIN_VERSION,
 672                version
 673            )
 674        }
 675
 676        let scratch_dir = paths::data_dir().join("node");
 677        fs::create_dir(&scratch_dir).await.ok();
 678        fs::create_dir(scratch_dir.join("cache")).await.ok();
 679
 680        let mut this = Self {
 681            node,
 682            npm,
 683            global_node_modules: PathBuf::default(),
 684            scratch_dir,
 685        };
 686        let output = this.run_npm_subcommand(None, None, "root", &["-g"]).await?;
 687        this.global_node_modules =
 688            PathBuf::from(String::from_utf8_lossy(&output.stdout).to_string());
 689
 690        Ok(this)
 691    }
 692
 693    async fn detect() -> std::result::Result<Self, DetectError> {
 694        let node = which::which("node").map_err(DetectError::NotInPath)?;
 695        let npm = which::which("npm").map_err(DetectError::NotInPath)?;
 696        Self::new(node, npm).await.map_err(DetectError::Other)
 697    }
 698}
 699
 700enum DetectError {
 701    NotInPath(which::Error),
 702    Other(anyhow::Error),
 703}
 704
 705impl Display for DetectError {
 706    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 707        match self {
 708            DetectError::NotInPath(err) => {
 709                write!(f, "system Node.js wasn't found on PATH: {}", err)
 710            }
 711            DetectError::Other(err) => {
 712                write!(f, "checking system Node.js failed with error: {}", err)
 713            }
 714        }
 715    }
 716}
 717
 718#[async_trait::async_trait]
 719impl NodeRuntimeTrait for SystemNodeRuntime {
 720    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
 721        Box::new(self.clone())
 722    }
 723
 724    fn binary_path(&self) -> Result<PathBuf> {
 725        Ok(self.node.clone())
 726    }
 727
 728    async fn run_npm_subcommand(
 729        &self,
 730        directory: Option<&Path>,
 731        proxy: Option<&Url>,
 732        subcommand: &str,
 733        args: &[&str],
 734    ) -> anyhow::Result<Output> {
 735        let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
 736        let mut command = util::command::new_command(npm_command.path);
 737        command.args(npm_command.args);
 738        command.envs(npm_command.env);
 739        if let Some(directory) = directory {
 740            command.current_dir(directory);
 741        }
 742        let output = command.output().await?;
 743        anyhow::ensure!(
 744            output.status.success(),
 745            "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
 746            String::from_utf8_lossy(&output.stdout),
 747            String::from_utf8_lossy(&output.stderr)
 748        );
 749        Ok(output)
 750    }
 751
 752    async fn npm_command(
 753        &self,
 754        prefix_dir: Option<&Path>,
 755        proxy: Option<&Url>,
 756        subcommand: &str,
 757        args: &[&str],
 758    ) -> Result<NpmCommand> {
 759        let command_args = build_npm_command_args(
 760            None,
 761            prefix_dir,
 762            &self.scratch_dir.join("cache"),
 763            None,
 764            None,
 765            proxy,
 766            subcommand,
 767            args,
 768        );
 769        let command_env = npm_command_env(Some(&self.node));
 770
 771        Ok(NpmCommand {
 772            path: self.npm.clone(),
 773            args: command_args,
 774            env: command_env,
 775        })
 776    }
 777
 778    async fn npm_package_installed_version(
 779        &self,
 780        local_package_directory: &Path,
 781        name: &str,
 782    ) -> Result<Option<Version>> {
 783        read_package_installed_version(local_package_directory.join("node_modules"), name).await
 784        // todo: allow returning a globally installed version (requires callers not to hard-code the path)
 785    }
 786}
 787
 788pub async fn read_package_installed_version(
 789    node_module_directory: PathBuf,
 790    name: &str,
 791) -> Result<Option<Version>> {
 792    let package_json_path = node_module_directory.join(name).join("package.json");
 793
 794    let mut file = match fs::File::open(package_json_path).await {
 795        Ok(file) => file,
 796        Err(err) => {
 797            if err.kind() == io::ErrorKind::NotFound {
 798                return Ok(None);
 799            }
 800
 801            Err(err)?
 802        }
 803    };
 804
 805    #[derive(Deserialize)]
 806    struct PackageJson {
 807        version: Version,
 808    }
 809
 810    let mut contents = String::new();
 811    file.read_to_string(&mut contents).await?;
 812    let package_json: PackageJson = serde_json::from_str(&contents)?;
 813    Ok(Some(package_json.version))
 814}
 815
 816#[derive(Clone)]
 817pub struct UnavailableNodeRuntime {
 818    error_message: Arc<String>,
 819}
 820
 821#[async_trait::async_trait]
 822impl NodeRuntimeTrait for UnavailableNodeRuntime {
 823    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
 824        Box::new(self.clone())
 825    }
 826    fn binary_path(&self) -> Result<PathBuf> {
 827        bail!("{}", self.error_message)
 828    }
 829
 830    async fn run_npm_subcommand(
 831        &self,
 832        _: Option<&Path>,
 833        _: Option<&Url>,
 834        _: &str,
 835        _: &[&str],
 836    ) -> anyhow::Result<Output> {
 837        bail!("{}", self.error_message)
 838    }
 839
 840    async fn npm_command(
 841        &self,
 842        _: Option<&Path>,
 843        _proxy: Option<&Url>,
 844        _subcommand: &str,
 845        _args: &[&str],
 846    ) -> Result<NpmCommand> {
 847        bail!("{}", self.error_message)
 848    }
 849
 850    async fn npm_package_installed_version(
 851        &self,
 852        _local_package_directory: &Path,
 853        _: &str,
 854    ) -> Result<Option<Version>> {
 855        bail!("{}", self.error_message)
 856    }
 857}
 858
 859fn proxy_argument(proxy: Option<&Url>) -> Option<String> {
 860    let mut proxy = proxy.cloned()?;
 861    // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
 862    // NodeRuntime without environment information can not parse `localhost`
 863    // correctly.
 864    // TODO: map to `[::1]` if we are using ipv6
 865    if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
 866    {
 867        // When localhost is a valid Host, so is `127.0.0.1`
 868        let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
 869    }
 870
 871    Some(proxy.as_str().to_string())
 872}
 873
 874fn build_npm_command_args(
 875    entrypoint: Option<&Path>,
 876    prefix_dir: Option<&Path>,
 877    cache_dir: &Path,
 878    user_config: Option<&Path>,
 879    global_config: Option<&Path>,
 880    proxy: Option<&Url>,
 881    subcommand: &str,
 882    args: &[&str],
 883) -> Vec<String> {
 884    let mut command_args = Vec::new();
 885    if let Some(entrypoint) = entrypoint {
 886        command_args.push(entrypoint.to_string_lossy().into_owned());
 887    }
 888    if let Some(prefix_dir) = prefix_dir {
 889        command_args.push("--prefix".into());
 890        command_args.push(prefix_dir.to_string_lossy().into_owned());
 891    }
 892    command_args.push(subcommand.to_string());
 893    command_args.push(format!("--cache={}", cache_dir.display()));
 894    if let Some(user_config) = user_config {
 895        command_args.push("--userconfig".into());
 896        command_args.push(user_config.to_string_lossy().into_owned());
 897    }
 898    if let Some(global_config) = global_config {
 899        command_args.push("--globalconfig".into());
 900        command_args.push(global_config.to_string_lossy().into_owned());
 901    }
 902    if let Some(proxy_arg) = proxy_argument(proxy) {
 903        command_args.push("--proxy".into());
 904        command_args.push(proxy_arg);
 905    }
 906    command_args.extend(args.into_iter().map(|a| a.to_string()));
 907    command_args
 908}
 909
 910fn npm_command_env(node_binary: Option<&Path>) -> HashMap<String, String> {
 911    let mut command_env = HashMap::new();
 912    if let Some(node_binary) = node_binary {
 913        let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default();
 914        command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned());
 915    }
 916
 917    if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) {
 918        if !node_ca_certs.is_empty() {
 919            command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs);
 920        }
 921    }
 922
 923    #[cfg(windows)]
 924    {
 925        if let Some(val) = env::var("SYSTEMROOT")
 926            .context("Missing environment variable: SYSTEMROOT!")
 927            .log_err()
 928        {
 929            command_env.insert("SYSTEMROOT".into(), val);
 930        }
 931        if let Some(val) = env::var("ComSpec")
 932            .context("Missing environment variable: ComSpec!")
 933            .log_err()
 934        {
 935            command_env.insert("ComSpec".into(), val);
 936        }
 937    }
 938
 939    command_env
 940}
 941
 942#[cfg(test)]
 943mod tests {
 944    use std::path::Path;
 945
 946    use http_client::Url;
 947
 948    use super::{build_npm_command_args, proxy_argument};
 949
 950    // Map localhost to 127.0.0.1
 951    // NodeRuntime without environment information can not parse `localhost` correctly.
 952    #[test]
 953    fn test_proxy_argument_map_localhost_proxy() {
 954        const CASES: [(&str, &str); 4] = [
 955            // Map localhost to 127.0.0.1
 956            ("http://localhost:9090/", "http://127.0.0.1:9090/"),
 957            ("https://google.com/", "https://google.com/"),
 958            (
 959                "http://username:password@proxy.thing.com:8080/",
 960                "http://username:password@proxy.thing.com:8080/",
 961            ),
 962            // Test when localhost is contained within a different part of the URL
 963            (
 964                "http://username:localhost@localhost:8080/",
 965                "http://username:localhost@127.0.0.1:8080/",
 966            ),
 967        ];
 968
 969        for (proxy, mapped_proxy) in CASES {
 970            let proxy = Url::parse(proxy).unwrap();
 971            let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly");
 972            assert_eq!(
 973                proxy, mapped_proxy,
 974                "Incorrectly mapped localhost to 127.0.0.1"
 975            );
 976        }
 977    }
 978
 979    #[test]
 980    fn test_build_npm_command_args_inserts_prefix_before_subcommand() {
 981        let args = build_npm_command_args(
 982            None,
 983            Some(Path::new("/tmp/zed-prefix")),
 984            Path::new("/tmp/cache"),
 985            None,
 986            None,
 987            None,
 988            "exec",
 989            &["--yes", "--", "agent-package"],
 990        );
 991
 992        assert_eq!(
 993            args,
 994            vec![
 995                "--prefix".to_string(),
 996                "/tmp/zed-prefix".to_string(),
 997                "exec".to_string(),
 998                "--cache=/tmp/cache".to_string(),
 999                "--yes".to_string(),
1000                "--".to_string(),
1001                "agent-package".to_string(),
1002            ]
1003        );
1004    }
1005
1006    #[test]
1007    fn test_build_npm_command_args_keeps_entrypoint_before_prefix() {
1008        let args = build_npm_command_args(
1009            Some(Path::new("/tmp/npm-cli.js")),
1010            Some(Path::new("/tmp/zed-prefix")),
1011            Path::new("/tmp/cache"),
1012            None,
1013            None,
1014            None,
1015            "exec",
1016            &["--yes"],
1017        );
1018
1019        assert_eq!(
1020            args,
1021            vec![
1022                "/tmp/npm-cli.js".to_string(),
1023                "--prefix".to_string(),
1024                "/tmp/zed-prefix".to_string(),
1025                "exec".to_string(),
1026                "--cache=/tmp/cache".to_string(),
1027                "--yes".to_string(),
1028            ]
1029        );
1030    }
1031}