963 lines · 31.2 KB · Rust
RawDownload
use anyhow::{Context as _, Result, anyhow, bail};use async_compression::futures::bufread::GzipDecoder;use async_tar::Archive;use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};use http_client::{Host, HttpClient, Url};use log::Level;use semver::Version;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, subcommand: &str, args: &[&str]) -> Result<NpmCommand> {        let http = self.0.lock().await.http.clone();        self.instance()            .await            .npm_command(http.proxy(), subcommand, args)            .await    }     pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {        let http = self.0.lock().await.http.clone();        let output = self            .instance()            .await            .run_npm_subcommand(                None,                http.proxy(),                "info",                &[                    name,                    "--json",                    "--fetch-retry-mintimeout",                    "2000",                    "--fetch-retry-maxtimeout",                    "5000",                    "--fetch-timeout",                    "5000",                ],            )            .await?;         let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?;        info.dist_tags            .latest            .or_else(|| info.versions.pop())            .with_context(|| format!("no version found for npm package {name}"))    }     pub async fn npm_install_packages(        &self,        directory: &Path,        packages: &[(&str, &str)],    ) -> Result<()> {        if packages.is_empty() {            return Ok(());        }         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 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() {            return true;        }         let Some(installed_version) = self            .npm_package_installed_version(local_package_directory, package_name)            .await            .log_err()            .flatten()        else {            return true;        };         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>,} #[derive(Debug, Deserialize, Default)]pub struct NpmInfoDistTags {    latest: Option<Version>,} #[async_trait::async_trait]trait NodeRuntimeTrait: Send + Sync {    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait>;    fn binary_path(&self) -> Result<PathBuf>;     async fn run_npm_subcommand(        &self,        directory: Option<&Path>,        proxy: Option<&Url>,        subcommand: &str,        args: &[&str],    ) -> Result<Output>;     async fn npm_command(        &self,        proxy: Option<&Url>,        subcommand: &str,        args: &[&str],    ) -> Result<NpmCommand>;     async fn npm_package_installed_version(        &self,        local_package_directory: &Path,        name: &str,    ) -> Result<Option<Version>>;} #[derive(Clone)]struct ManagedNodeRuntime {    installation_path: PathBuf,} impl ManagedNodeRuntime {    const VERSION: &str = "v24.11.0";     #[cfg(not(windows))]    const NODE_PATH: &str = "bin/node";    #[cfg(windows)]    const NODE_PATH: &str = "node.exe";     #[cfg(not(windows))]    const NPM_PATH: &str = "bin/npm";    #[cfg(windows)]    const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";     async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Self> {        log::info!("Node runtime install_if_needed");         let os = match consts::OS {            "macos" => "darwin",            "linux" => "linux",            "windows" => "win",            other => bail!("Running on unsupported os: {other}"),        };         let arch = match consts::ARCH {            "x86_64" => "x64",            "aarch64" => "arm64",            other => bail!("Running on unsupported architecture: {other}"),        };         let version = Self::VERSION;        let folder_name = format!("node-{version}-{os}-{arch}");        let node_containing_dir = paths::data_dir().join("node");        let node_dir = node_containing_dir.join(folder_name);        let node_binary = node_dir.join(Self::NODE_PATH);        let npm_file = node_dir.join(Self::NPM_PATH);        let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());         let valid = if fs::metadata(&node_binary).await.is_ok() {            let result = util::command::new_command(&node_binary)                .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)                .arg(npm_file)                .arg("--version")                .args(["--cache".into(), node_dir.join("cache")])                .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])                .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])                .output()                .await;            match result {                Ok(output) => {                    if output.status.success() {                        true                    } else {                        log::warn!(                            "Zed managed Node.js binary at {} failed check with output: {:?}",                            node_binary.display(),                            output                        );                        false                    }                }                Err(err) => {                    log::warn!(                        "Zed managed Node.js binary at {} failed check, so re-downloading it. \                        Error: {}",                        node_binary.display(),                        err                    );                    false                }            }        } else {            false        };         if !valid {            _ = fs::remove_dir_all(&node_containing_dir).await;            fs::create_dir(&node_containing_dir)                .await                .context("error creating node containing dir")?;             let archive_type = match consts::OS {                "macos" | "linux" => ArchiveType::TarGz,                "windows" => ArchiveType::Zip,                other => bail!("Running on unsupported os: {other}"),            };             let version = Self::VERSION;            let file_name = format!(                "node-{version}-{os}-{arch}.{extension}",                extension = match archive_type {                    ArchiveType::TarGz => "tar.gz",                    ArchiveType::Zip => "zip",                }            );             let url = format!("https://nodejs.org/dist/{version}/{file_name}");            log::info!("Downloading Node.js binary from {url}");            let mut response = http                .get(&url, Default::default(), true)                .await                .context("error downloading Node binary tarball")?;            log::info!("Download of Node.js complete, extracting...");             let body = response.body_mut();            match archive_type {                ArchiveType::TarGz => {                    let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));                    let archive = Archive::new(decompressed_bytes);                    archive.unpack(&node_containing_dir).await?;

Lines 1–500 of 963.