1660 lines · 53.6 KB · Rust
RawDownload
use anyhow::{Context as _, Result, anyhow, bail};use async_compression::futures::bufread::GzipDecoder;use async_tar::Archive;use chrono::{DateTime, Utc};use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};use http_client::{Host, HttpClient, Url};use log::Level;use semver::{Version, VersionReq};use serde::Deserialize;use smol::io::BufReader;use smol::{fs, lock::Mutex};use std::collections::HashMap;use std::fmt::Display;use std::{    env::{self, consts},    ffi::OsString,    io,    net::{IpAddr, Ipv4Addr},    path::{Path, PathBuf},    process::Output,    sync::Arc,};use util::ResultExt;use util::archive::extract_zip; const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS"; #[derive(Clone, Debug, Default, Eq, PartialEq)]pub struct NodeBinaryOptions {    pub allow_path_lookup: bool,    pub allow_binary_download: bool,    pub use_paths: Option<(PathBuf, PathBuf)>,} /// Use this when you need to launch npm as a long-lived process (for example, an agent server),/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.#[derive(Clone, Debug)]pub struct NpmCommand {    pub path: PathBuf,    pub args: Vec<String>,    pub env: HashMap<String, String>,} pub enum VersionStrategy<'a> {    /// Install if current version doesn't match pinned version    Pin(&'a Version),    /// Install if current version is older than latest version    Latest(&'a Version),} #[derive(Clone)]pub struct NodeRuntime(Arc<Mutex<NodeRuntimeState>>); struct NodeRuntimeState {    http: Arc<dyn HttpClient>,    instance: Option<Box<dyn NodeRuntimeTrait>>,    last_options: Option<NodeBinaryOptions>,    options: watch::Receiver<Option<NodeBinaryOptions>>,    shell_env_loaded: Shared<oneshot::Receiver<()>>,} impl NodeRuntime {    pub fn new(        http: Arc<dyn HttpClient>,        shell_env_loaded: Option<oneshot::Receiver<()>>,        options: watch::Receiver<Option<NodeBinaryOptions>>,    ) -> Self {        NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {            http,            instance: None,            last_options: None,            options,            shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(),        })))    }     pub fn unavailable() -> Self {        NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {            http: Arc::new(http_client::BlockedHttpClient),            instance: None,            last_options: None,            options: watch::channel(Some(NodeBinaryOptions::default())).1,            shell_env_loaded: oneshot::channel().1.shared(),        })))    }     async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {        let mut state = self.0.lock().await;         let options = loop {            if let Some(options) = state.options.borrow().as_ref() {                break options.clone();            }            match state.options.changed().await {                Ok(()) => {}                // failure case not cached                Err(err) => {                    return Box::new(UnavailableNodeRuntime {                        error_message: err.to_string().into(),                    }) as Box<dyn NodeRuntimeTrait>;                }            }        };         if state.last_options.as_ref() != Some(&options) {            state.instance.take();        }        if let Some(instance) = state.instance.as_ref() {            return instance.boxed_clone();        }         if let Some((node, npm)) = options.use_paths.as_ref() {            let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await {                Ok(instance) => {                    log::info!("using Node.js from `node.path` in settings: {:?}", instance);                    Box::new(instance)                }                Err(err) => {                    // failure case not cached, since it's cheap to check again                    return Box::new(UnavailableNodeRuntime {                        error_message: format!(                            "failure checking Node.js from `node.path` in settings ({}): {:?}",                            node.display(),                            err                        )                        .into(),                    });                }            };            state.instance = Some(instance.boxed_clone());            state.last_options = Some(options);            return instance;        }         let system_node_error = if options.allow_path_lookup {            state.shell_env_loaded.clone().await.ok();            match SystemNodeRuntime::detect().await {                Ok(instance) => {                    log::info!("using Node.js found on PATH: {:?}", instance);                    state.instance = Some(instance.boxed_clone());                    state.last_options = Some(options);                    return Box::new(instance) as Box<dyn NodeRuntimeTrait>;                }                Err(err) => Some(err),            }        } else {            None        };         let instance = if options.allow_binary_download {            let (log_level, why_using_managed) = match system_node_error {                Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()),                Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()),                None => (                    Level::Info,                    "`node.ignore_system_version` is `true` in settings".to_string(),                ),            };            match ManagedNodeRuntime::install_if_needed(&state.http).await {                Ok(instance) => {                    log::log!(                        log_level,                        "using Zed managed Node.js at {} since {}",                        instance.installation_path.display(),                        why_using_managed                    );                    Box::new(instance) as Box<dyn NodeRuntimeTrait>                }                Err(err) => {                    // failure case is cached, since downloading + installing may be expensive. The                    // downside of this is that it may fail due to an intermittent network issue.                    //                    // TODO: Have `install_if_needed` indicate which failure cases are retryable                    // and/or have shared tracking of when internet is available.                    Box::new(UnavailableNodeRuntime {                        error_message: format!(                            "failure while downloading and/or installing Zed managed Node.js, \                            restart Zed to retry: {}",                            err                        )                        .into(),                    }) as Box<dyn NodeRuntimeTrait>                }            }        } else if let Some(system_node_error) = system_node_error {            // failure case not cached, since it's cheap to check again            //            // TODO: When support is added for setting `options.allow_binary_download`, update this            // error message.            return Box::new(UnavailableNodeRuntime {                error_message: format!(                    "failure while checking system Node.js from PATH: {}",                    system_node_error                )                .into(),            });        } else {            // failure case is cached because it will always happen with these options            //            // TODO: When support is added for setting `options.allow_binary_download`, update this            // error message.            Box::new(UnavailableNodeRuntime {                error_message: "`node` settings do not allow any way to use Node.js"                    .to_string()                    .into(),            })        };         state.instance = Some(instance.boxed_clone());        state.last_options = Some(options);        instance    }     pub async fn binary_path(&self) -> Result<PathBuf> {        self.instance().await.binary_path()    }     pub async fn run_npm_subcommand(        &self,        directory: Option<&Path>,        subcommand: &str,        args: &[&str],    ) -> Result<Output> {        let http = self.0.lock().await.http.clone();        self.instance()            .await            .run_npm_subcommand(directory, http.proxy(), subcommand, args)            .await    }     pub async fn npm_package_installed_version(        &self,        local_package_directory: &Path,        name: &str,    ) -> Result<Option<Version>> {        self.instance()            .await            .npm_package_installed_version(local_package_directory, name)            .await    }     pub async fn npm_command(        &self,        prefix_dir: Option<&Path>,        subcommand: &str,        args: &[&str],    ) -> Result<NpmCommand> {        let http = self.0.lock().await.http.clone();        self.instance()            .await            .npm_command(prefix_dir, http.proxy(), subcommand, args)            .await    }     pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {        self.npm_package_latest_version_with_requirement(name, None)            .await    }     pub async fn npm_package_latest_version_with_requirement(        &self,        name: &str,        version_requirement: Option<&VersionReq>,    ) -> Result<Version> {        let http = self.0.lock().await.http.clone();        let instance = self.instance().await;        let output = instance            .run_npm_subcommand(                None,                http.proxy(),                "info",                &[                    name,                    "--json",                    "--fetch-retry-mintimeout",                    "2000",                    "--fetch-retry-maxtimeout",                    "5000",                    "--fetch-timeout",                    "5000",                ],            )            .await?;         let info: NpmInfo = deserialize_npm_info_from_response(&output.stdout).map_err(|e| {            anyhow::anyhow!(                "failed to parse npm info response: {e}\nstdout: {}",                String::from_utf8_lossy(&output.stdout)            )        })?;        let before = npm_config_before(instance.as_ref(), http.proxy())            .await            .context("getting npm before config")            .log_err()            .flatten();        let latest_dist_tag = info.dist_tags.latest.clone();        let selected_version =            select_npm_package_version(name, info, before.as_deref(), version_requirement)?;        log::debug!(            "selected latest npm package version package={name:?} version_requirement={version_requirement:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}"        );        Ok(selected_version)    }     pub async fn npm_install_packages(        &self,        directory: &Path,        packages: &[(&str, &str)],    ) -> Result<()> {        if packages.is_empty() {            return Ok(());        }         log::debug!(            "installing npm packages directory={} packages={packages:?}",            directory.display()        );         let packages: Vec<_> = packages            .iter()            .map(|(name, version)| format!("{name}@{version}"))            .collect();         let arguments: Vec<_> = packages            .iter()            .map(|p| p.as_str())            .chain([                "--save-exact",                "--fetch-retry-mintimeout",                "2000",                "--fetch-retry-maxtimeout",                "5000",                "--fetch-timeout",                "5000",            ])            .collect();         // This is also wrong because the directory is wrong.        self.run_npm_subcommand(Some(directory), "install", &arguments)            .await?;        Ok(())    }     pub async fn npm_install_latest_packages(        &self,        directory: &Path,        package_names: &[&str],    ) -> Result<()> {        // Let npm apply user config such as `before` and `min-release-age` during resolution.        log::debug!(            "installing latest npm packages directory={} packages={package_names:?}",            directory.display()        );        let packages = package_names            .iter()            .map(|package_name| (*package_name, "latest"))            .collect::<Vec<_>>();        self.npm_install_packages(directory, &packages).await    }     pub async fn should_install_npm_package(        &self,        package_name: &str,        local_executable_path: &Path,        local_package_directory: &Path,        version_strategy: VersionStrategy<'_>,    ) -> bool {        // In the case of the local system not having the package installed,        // or in the instances where we fail to parse package.json data,        // we attempt to install the package.        if fs::metadata(local_executable_path).await.is_err() {            log::debug!(                "npm package cache miss package={package_name:?} reason=missing-executable executable={}",                local_executable_path.display()            );            return true;        }         let Some(installed_version) = self            .npm_package_installed_version(local_package_directory, package_name)            .await            .log_err()            .flatten()        else {            log::debug!(                "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}",                local_package_directory.display()            );            return true;        };         let version_strategy_label = match &version_strategy {            VersionStrategy::Pin(version) => format!("pin:{version}"),            VersionStrategy::Latest(version) => format!("latest:{version}"),        };        let should_install =            should_install_npm_package_version(&installed_version, version_strategy);        log::debug!(            "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}"        );        should_install    }} fn should_install_npm_package_version(    installed_version: &Version,    version_strategy: VersionStrategy<'_>,) -> bool {    match version_strategy {        VersionStrategy::Pin(pinned_version) => installed_version != pinned_version,        VersionStrategy::Latest(latest_version) => installed_version < latest_version,    }} enum ArchiveType {    TarGz,    Zip,} #[derive(Debug, Deserialize)]#[serde(rename_all = "kebab-case")]pub struct NpmInfo {    #[serde(default)]    dist_tags: NpmInfoDistTags,    versions: Vec<Version>,    #[serde(default, deserialize_with = "deserialize_npm_info_time")]    time: HashMap<String, String>,} /// Parse NpmInfo from npm info --json output, handling both v11 and >= v12 formats.fn deserialize_npm_info_from_response(data: &[u8]) -> Result<NpmInfo, serde_json::Error> {    let value: serde_json::Value = serde_json::from_slice(data)?;     // npm >= 12 returns an array with one object: [ { ... } ]    if let serde_json::Value::Array(arr) = &value {        if arr.len() == 1 {            return NpmInfo::deserialize(&arr[0]);        }    }     // npm <= v11 returns a bare JSON object: { ... }    NpmInfo::deserialize(value)} #[derive(Debug, Deserialize, Default)]pub struct NpmInfoDistTags {    latest: Option<Version>,} // Some registries put non-string values in the `time` map: JFrog Artifactory emits// `"unpublished": null`, and npm itself reports `unpublished` as an object when a// package has had versions unpublished. Only version keys map to the RFC 3339 strings// we read, so keep the string entries and drop the rest rather than failing to parse// the entire `npm info` response (which would block language server installation).fn deserialize_npm_info_time<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>where    D: serde::Deserializer<'de>,{    let entries = HashMap::<String, serde_json::Value>::deserialize(deserializer)?;    Ok(entries        .into_iter()        .filter_map(|(key, value)| match value {            serde_json::Value::String(value) => Some((key, value)),            _ => None,        })        .collect())} #[derive(Debug, Deserialize)]struct NpmConfig {    #[serde(default)]    before: Option<String>,} async fn npm_config_before(    node_runtime: &dyn NodeRuntimeTrait,    proxy: Option<&Url>,) -> Result<Option<String>> {    // `npm config get before` renders Date values for display. The JSON config output keeps the    // computed cutoff in the same ISO format used by `npm info --json` release times.    let output = node_runtime        .run_npm_subcommand(None, proxy, "config", &["list", "--json"])        .await?;    let config: NpmConfig = serde_json::from_slice(&output.stdout)?;    Ok(config        .before        .filter(|before| !before.trim().is_empty() && before != "null"))} fn select_npm_package_version(    package_name: &str,    mut info: NpmInfo,    before: Option<&str>,    version_requirement: Option<&VersionReq>,) -> Result<Version> {    if let Some(version_requirement) = version_requirement {        info.versions            .retain(|version| version_requirement.matches(version));        info.versions.sort();        info.dist_tags.latest = info

Lines 1–500 of 1660.