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    scratch_dir: PathBuf,
 647}
 648
 649impl SystemNodeRuntime {
 650    const MIN_VERSION: semver::Version = Version::new(22, 0, 0);
 651    async fn new(node: PathBuf, npm: PathBuf) -> Result<Self> {
 652        let output = util::command::new_command(&node)
 653            .arg("--version")
 654            .output()
 655            .await
 656            .with_context(|| format!("running node from {:?}", node))?;
 657        if !output.status.success() {
 658            anyhow::bail!(
 659                "failed to run node --version. stdout: {}, stderr: {}",
 660                String::from_utf8_lossy(&output.stdout),
 661                String::from_utf8_lossy(&output.stderr),
 662            );
 663        }
 664        let version_str = String::from_utf8_lossy(&output.stdout);
 665        let version = semver::Version::parse(version_str.trim().trim_start_matches('v'))?;
 666        if version < Self::MIN_VERSION {
 667            anyhow::bail!(
 668                "node at {} is too old. want: {}, got: {}",
 669                node.to_string_lossy(),
 670                Self::MIN_VERSION,
 671                version
 672            )
 673        }
 674
 675        let scratch_dir = paths::data_dir().join("node");
 676        fs::create_dir(&scratch_dir).await.ok();
 677        fs::create_dir(scratch_dir.join("cache")).await.ok();
 678
 679        Ok(Self {
 680            node,
 681            npm,
 682            scratch_dir,
 683        })
 684    }
 685
 686    async fn detect() -> std::result::Result<Self, DetectError> {
 687        let node = which::which("node").map_err(DetectError::NotInPath)?;
 688        let npm = which::which("npm").map_err(DetectError::NotInPath)?;
 689        Self::new(node, npm).await.map_err(DetectError::Other)
 690    }
 691}
 692
 693enum DetectError {
 694    NotInPath(which::Error),
 695    Other(anyhow::Error),
 696}
 697
 698impl Display for DetectError {
 699    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 700        match self {
 701            DetectError::NotInPath(err) => {
 702                write!(f, "system Node.js wasn't found on PATH: {}", err)
 703            }
 704            DetectError::Other(err) => {
 705                write!(f, "checking system Node.js failed with error: {}", err)
 706            }
 707        }
 708    }
 709}
 710
 711#[async_trait::async_trait]
 712impl NodeRuntimeTrait for SystemNodeRuntime {
 713    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
 714        Box::new(self.clone())
 715    }
 716
 717    fn binary_path(&self) -> Result<PathBuf> {
 718        Ok(self.node.clone())
 719    }
 720
 721    async fn run_npm_subcommand(
 722        &self,
 723        directory: Option<&Path>,
 724        proxy: Option<&Url>,
 725        subcommand: &str,
 726        args: &[&str],
 727    ) -> anyhow::Result<Output> {
 728        let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
 729        let mut command = util::command::new_command(npm_command.path);
 730        command.args(npm_command.args);
 731        command.envs(npm_command.env);
 732        if let Some(directory) = directory {
 733            command.current_dir(directory);
 734        }
 735        let output = command.output().await?;
 736        anyhow::ensure!(
 737            output.status.success(),
 738            "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
 739            String::from_utf8_lossy(&output.stdout),
 740            String::from_utf8_lossy(&output.stderr)
 741        );
 742        Ok(output)
 743    }
 744
 745    async fn npm_command(
 746        &self,
 747        prefix_dir: Option<&Path>,
 748        proxy: Option<&Url>,
 749        subcommand: &str,
 750        args: &[&str],
 751    ) -> Result<NpmCommand> {
 752        let command_args = build_npm_command_args(
 753            None,
 754            prefix_dir,
 755            &self.scratch_dir.join("cache"),
 756            None,
 757            None,
 758            proxy,
 759            subcommand,
 760            args,
 761        );
 762        let command_env = npm_command_env(Some(&self.node));
 763
 764        Ok(NpmCommand {
 765            path: self.npm.clone(),
 766            args: command_args,
 767            env: command_env,
 768        })
 769    }
 770
 771    async fn npm_package_installed_version(
 772        &self,
 773        local_package_directory: &Path,
 774        name: &str,
 775    ) -> Result<Option<Version>> {
 776        read_package_installed_version(local_package_directory.join("node_modules"), name).await
 777        // todo: allow returning a globally installed version (requires callers not to hard-code the path)
 778    }
 779}
 780
 781pub async fn read_package_installed_version(
 782    node_module_directory: PathBuf,
 783    name: &str,
 784) -> Result<Option<Version>> {
 785    let package_json_path = node_module_directory.join(name).join("package.json");
 786
 787    let mut file = match fs::File::open(package_json_path).await {
 788        Ok(file) => file,
 789        Err(err) => {
 790            if err.kind() == io::ErrorKind::NotFound {
 791                return Ok(None);
 792            }
 793
 794            Err(err)?
 795        }
 796    };
 797
 798    #[derive(Deserialize)]
 799    struct PackageJson {
 800        version: Version,
 801    }
 802
 803    let mut contents = String::new();
 804    file.read_to_string(&mut contents).await?;
 805    let package_json: PackageJson = serde_json::from_str(&contents)?;
 806    Ok(Some(package_json.version))
 807}
 808
 809#[derive(Clone)]
 810pub struct UnavailableNodeRuntime {
 811    error_message: Arc<String>,
 812}
 813
 814#[async_trait::async_trait]
 815impl NodeRuntimeTrait for UnavailableNodeRuntime {
 816    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
 817        Box::new(self.clone())
 818    }
 819    fn binary_path(&self) -> Result<PathBuf> {
 820        bail!("{}", self.error_message)
 821    }
 822
 823    async fn run_npm_subcommand(
 824        &self,
 825        _: Option<&Path>,
 826        _: Option<&Url>,
 827        _: &str,
 828        _: &[&str],
 829    ) -> anyhow::Result<Output> {
 830        bail!("{}", self.error_message)
 831    }
 832
 833    async fn npm_command(
 834        &self,
 835        _: Option<&Path>,
 836        _proxy: Option<&Url>,
 837        _subcommand: &str,
 838        _args: &[&str],
 839    ) -> Result<NpmCommand> {
 840        bail!("{}", self.error_message)
 841    }
 842
 843    async fn npm_package_installed_version(
 844        &self,
 845        _local_package_directory: &Path,
 846        _: &str,
 847    ) -> Result<Option<Version>> {
 848        bail!("{}", self.error_message)
 849    }
 850}
 851
 852fn proxy_argument(proxy: Option<&Url>) -> Option<String> {
 853    let mut proxy = proxy.cloned()?;
 854    // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
 855    // NodeRuntime without environment information can not parse `localhost`
 856    // correctly.
 857    // TODO: map to `[::1]` if we are using ipv6
 858    if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
 859    {
 860        // When localhost is a valid Host, so is `127.0.0.1`
 861        let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
 862    }
 863
 864    Some(proxy.as_str().to_string())
 865}
 866
 867fn build_npm_command_args(
 868    entrypoint: Option<&Path>,
 869    prefix_dir: Option<&Path>,
 870    cache_dir: &Path,
 871    user_config: Option<&Path>,
 872    global_config: Option<&Path>,
 873    proxy: Option<&Url>,
 874    subcommand: &str,
 875    args: &[&str],
 876) -> Vec<String> {
 877    let mut command_args = Vec::new();
 878    if let Some(entrypoint) = entrypoint {
 879        command_args.push(entrypoint.to_string_lossy().into_owned());
 880    }
 881    if let Some(prefix_dir) = prefix_dir {
 882        command_args.push("--prefix".into());
 883        command_args.push(prefix_dir.to_string_lossy().into_owned());
 884    }
 885    command_args.push(subcommand.to_string());
 886    command_args.push(format!("--cache={}", cache_dir.display()));
 887    if let Some(user_config) = user_config {
 888        command_args.push("--userconfig".into());
 889        command_args.push(user_config.to_string_lossy().into_owned());
 890    }
 891    if let Some(global_config) = global_config {
 892        command_args.push("--globalconfig".into());
 893        command_args.push(global_config.to_string_lossy().into_owned());
 894    }
 895    if let Some(proxy_arg) = proxy_argument(proxy) {
 896        command_args.push("--proxy".into());
 897        command_args.push(proxy_arg);
 898    }
 899    command_args.extend(args.into_iter().map(|a| a.to_string()));
 900    command_args
 901}
 902
 903fn npm_command_env(node_binary: Option<&Path>) -> HashMap<String, String> {
 904    let mut command_env = HashMap::new();
 905    if let Some(node_binary) = node_binary {
 906        let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default();
 907        command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned());
 908    }
 909
 910    if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) {
 911        if !node_ca_certs.is_empty() {
 912            command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs);
 913        }
 914    }
 915
 916    #[cfg(windows)]
 917    {
 918        if let Some(val) = env::var("SYSTEMROOT")
 919            .context("Missing environment variable: SYSTEMROOT!")
 920            .log_err()
 921        {
 922            command_env.insert("SYSTEMROOT".into(), val);
 923        }
 924        if let Some(val) = env::var("ComSpec")
 925            .context("Missing environment variable: ComSpec!")
 926            .log_err()
 927        {
 928            command_env.insert("ComSpec".into(), val);
 929        }
 930    }
 931
 932    command_env
 933}
 934
 935#[cfg(test)]
 936mod tests {
 937    use std::path::Path;
 938
 939    use http_client::Url;
 940
 941    use super::{build_npm_command_args, proxy_argument};
 942
 943    // Map localhost to 127.0.0.1
 944    // NodeRuntime without environment information can not parse `localhost` correctly.
 945    #[test]
 946    fn test_proxy_argument_map_localhost_proxy() {
 947        const CASES: [(&str, &str); 4] = [
 948            // Map localhost to 127.0.0.1
 949            ("http://localhost:9090/", "http://127.0.0.1:9090/"),
 950            ("https://google.com/", "https://google.com/"),
 951            (
 952                "http://username:password@proxy.thing.com:8080/",
 953                "http://username:password@proxy.thing.com:8080/",
 954            ),
 955            // Test when localhost is contained within a different part of the URL
 956            (
 957                "http://username:localhost@localhost:8080/",
 958                "http://username:localhost@127.0.0.1:8080/",
 959            ),
 960        ];
 961
 962        for (proxy, mapped_proxy) in CASES {
 963            let proxy = Url::parse(proxy).unwrap();
 964            let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly");
 965            assert_eq!(
 966                proxy, mapped_proxy,
 967                "Incorrectly mapped localhost to 127.0.0.1"
 968            );
 969        }
 970    }
 971
 972    #[test]
 973    fn test_build_npm_command_args_inserts_prefix_before_subcommand() {
 974        let args = build_npm_command_args(
 975            None,
 976            Some(Path::new("/tmp/zed-prefix")),
 977            Path::new("/tmp/cache"),
 978            None,
 979            None,
 980            None,
 981            "exec",
 982            &["--yes", "--", "agent-package"],
 983        );
 984
 985        assert_eq!(
 986            args,
 987            vec![
 988                "--prefix".to_string(),
 989                "/tmp/zed-prefix".to_string(),
 990                "exec".to_string(),
 991                "--cache=/tmp/cache".to_string(),
 992                "--yes".to_string(),
 993                "--".to_string(),
 994                "agent-package".to_string(),
 995            ]
 996        );
 997    }
 998
 999    #[test]
1000    fn test_build_npm_command_args_keeps_entrypoint_before_prefix() {
1001        let args = build_npm_command_args(
1002            Some(Path::new("/tmp/npm-cli.js")),
1003            Some(Path::new("/tmp/zed-prefix")),
1004            Path::new("/tmp/cache"),
1005            None,
1006            None,
1007            None,
1008            "exec",
1009            &["--yes"],
1010        );
1011
1012        assert_eq!(
1013            args,
1014            vec![
1015                "/tmp/npm-cli.js".to_string(),
1016                "--prefix".to_string(),
1017                "/tmp/zed-prefix".to_string(),
1018                "exec".to_string(),
1019                "--cache=/tmp/cache".to_string(),
1020                "--yes".to_string(),
1021            ]
1022        );
1023    }
1024}