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, pub env: HashMap, } 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>); struct NodeRuntimeState { http: Arc, instance: Option>, last_options: Option, options: watch::Receiver>, shell_env_loaded: Shared>, } impl NodeRuntime { pub fn new( http: Arc, shell_env_loaded: Option>, options: watch::Receiver>, ) -> 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 { 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; } } }; 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; } 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 } 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 } } } 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 { self.instance().await.binary_path() } pub async fn run_npm_subcommand( &self, directory: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result { 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> { 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 { 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 { 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 { 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::>(); 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, #[serde(default, deserialize_with = "deserialize_npm_info_time")] time: HashMap, } /// Parse NpmInfo from npm info --json output, handling both v11 and >= v12 formats. fn deserialize_npm_info_from_response(data: &[u8]) -> Result { 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, } // 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, D::Error> where D: serde::Deserializer<'de>, { let entries = HashMap::::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, } async fn npm_config_before( node_runtime: &dyn NodeRuntimeTrait, proxy: Option<&Url>, ) -> Result> { // `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 { if let Some(version_requirement) = version_requirement { info.versions .retain(|version| version_requirement.matches(version)); info.versions.sort(); info.dist_tags.latest = info .dist_tags .latest .take() .filter(|version| version_requirement.matches(version)); } if let Some(before) = before && !info.time.is_empty() { let before_timestamp = DateTime::parse_from_rfc3339(before) .with_context(|| format!("parsing npm before config timestamp {before:?}"))? .with_timezone(&Utc); let latest_version = info.dist_tags.latest.as_ref(); if let Some(version) = latest_version && npm_version_was_published_before(version, &info.time, &before_timestamp)? { return Ok(version.clone()); } for version in info.versions.iter().rev() { if is_allowed_npm_version_before( version, latest_version, &info.time, &before_timestamp, version_requirement.is_some(), )? { return Ok(version.clone()); } } bail!("no version found for npm package {package_name} before {before}"); } info.dist_tags .latest .or_else(|| info.versions.pop()) .with_context(|| format!("no version found for npm package {package_name}")) } fn is_allowed_npm_version_before( version: &Version, latest_version: Option<&Version>, published_at_by_version: &HashMap, before: &DateTime, allow_prereleases: bool, ) -> Result { if (!allow_prereleases && !version.pre.is_empty()) || latest_version.is_some_and(|latest_version| version > latest_version) { return Ok(false); } npm_version_was_published_before(version, published_at_by_version, before) } fn npm_version_was_published_before( version: &Version, published_at_by_version: &HashMap, before: &DateTime, ) -> Result { let Some(published_at) = published_at_by_version.get(&version.to_string()) else { return Ok(false); }; let published_at = DateTime::parse_from_rfc3339(published_at) .with_context(|| format!("parsing npm release timestamp for version {version}"))? .with_timezone(&Utc); Ok(&published_at <= before) } #[async_trait::async_trait] trait NodeRuntimeTrait: Send + Sync { fn boxed_clone(&self) -> Box; fn binary_path(&self) -> Result; async fn run_npm_subcommand( &self, directory: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> Result; async fn npm_command( &self, prefix_dir: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> Result; async fn npm_package_installed_version( &self, local_package_directory: &Path, name: &str, ) -> Result>; } #[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) -> Result { 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?; } ArchiveType::Zip => extract_zip(&node_containing_dir, body).await?, } log::info!("Extracted Node.js to {}", node_containing_dir.display()) } _ = fs::remove_dir_all(node_dir.join("cache")).await; // Note: Not in the `if !valid {}` so we can populate these for existing installations _ = fs::create_dir(node_dir.join("cache")).await; _ = fs::write(node_dir.join("blank_user_npmrc"), []).await; _ = fs::write(node_dir.join("blank_global_npmrc"), []).await; anyhow::Ok(ManagedNodeRuntime { installation_path: node_dir, }) } } fn path_with_node_binary_prepended(node_binary: &Path) -> Option { let existing_path = env::var_os("PATH"); let node_bin_dir = node_binary.parent().map(|dir| dir.as_os_str()); match (existing_path, node_bin_dir) { (Some(existing_path), Some(node_bin_dir)) => { if let Ok(joined) = env::join_paths( [PathBuf::from(node_bin_dir)] .into_iter() .chain(env::split_paths(&existing_path)), ) { Some(joined) } else { Some(existing_path) } } (Some(existing_path), None) => Some(existing_path), (None, Some(node_bin_dir)) => Some(node_bin_dir.to_owned()), _ => None, } } #[async_trait::async_trait] impl NodeRuntimeTrait for ManagedNodeRuntime { fn boxed_clone(&self) -> Box { Box::new(self.clone()) } fn binary_path(&self) -> Result { Ok(self.installation_path.join(Self::NODE_PATH)) } async fn run_npm_subcommand( &self, directory: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> Result { let attempt = || async { let npm_command = self.npm_command(directory, proxy, subcommand, args).await?; let mut command = util::command::new_command(npm_command.path); command.args(npm_command.args); command.envs(npm_command.env); if let Some(directory) = directory { command.current_dir(directory); } command.output().await.map_err(|e| anyhow!("{e}")) }; let mut output = attempt().await; if output.is_err() { output = attempt().await; anyhow::ensure!( output.is_ok(), "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}", output.err() ); } if let Ok(output) = &output { anyhow::ensure!( output.status.success(), "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } output.map_err(|e| anyhow!("{e}")) } async fn npm_command( &self, prefix_dir: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> Result { let node_binary = self.installation_path.join(Self::NODE_PATH); let npm_file = self.installation_path.join(Self::NPM_PATH); anyhow::ensure!( smol::fs::metadata(&node_binary).await.is_ok(), "missing node binary file" ); anyhow::ensure!( smol::fs::metadata(&npm_file).await.is_ok(), "missing npm file" ); let command_args = build_npm_command_args( Some(&npm_file), prefix_dir, &self.installation_path.join("cache"), Some(&self.installation_path.join("blank_user_npmrc")), Some(&self.installation_path.join("blank_global_npmrc")), proxy, subcommand, args, ); let command_env = npm_command_env(&node_binary); Ok(NpmCommand { path: node_binary, args: command_args, env: command_env, }) } async fn npm_package_installed_version( &self, local_package_directory: &Path, name: &str, ) -> Result> { read_package_installed_version(local_package_directory.join("node_modules"), name).await } } #[derive(Debug, Clone)] pub struct SystemNodeRuntime { node: PathBuf, npm: PathBuf, scratch_dir: PathBuf, } impl SystemNodeRuntime { const MIN_VERSION: semver::Version = Version::new(22, 0, 0); async fn new(node: PathBuf, npm: PathBuf) -> Result { let output = util::command::new_command(&node) .arg("--version") .output() .await .with_context(|| format!("running node from {:?}", node))?; if !output.status.success() { anyhow::bail!( "failed to run node --version. stdout: {}, stderr: {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ); } let version_str = String::from_utf8_lossy(&output.stdout); let version = semver::Version::parse(version_str.trim().trim_start_matches('v'))?; if version < Self::MIN_VERSION { anyhow::bail!( "node at {} is too old. want: {}, got: {}", node.to_string_lossy(), Self::MIN_VERSION, version ) } let scratch_dir = paths::data_dir().join("node"); fs::create_dir(&scratch_dir).await.ok(); _ = fs::remove_dir_all(scratch_dir.join("cache")).await; fs::create_dir(scratch_dir.join("cache")).await.ok(); Ok(Self { node, npm, scratch_dir, }) } async fn detect() -> std::result::Result { let node = which::which("node").map_err(DetectError::NotInPath)?; let npm = which::which("npm").map_err(DetectError::NotInPath)?; Self::new(node, npm).await.map_err(DetectError::Other) } } enum DetectError { NotInPath(which::Error), Other(anyhow::Error), } impl Display for DetectError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DetectError::NotInPath(err) => { write!(f, "system Node.js wasn't found on PATH: {}", err) } DetectError::Other(err) => { write!(f, "checking system Node.js failed with error: {}", err) } } } } #[async_trait::async_trait] impl NodeRuntimeTrait for SystemNodeRuntime { fn boxed_clone(&self) -> Box { Box::new(self.clone()) } fn binary_path(&self) -> Result { Ok(self.node.clone()) } async fn run_npm_subcommand( &self, directory: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> anyhow::Result { let npm_command = self.npm_command(directory, proxy, subcommand, args).await?; let mut command = util::command::new_command(npm_command.path); command.args(npm_command.args); command.envs(npm_command.env); if let Some(directory) = directory { command.current_dir(directory); } let output = command.output().await?; anyhow::ensure!( output.status.success(), "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); Ok(output) } async fn npm_command( &self, prefix_dir: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> Result { let command_args = build_npm_command_args( None, prefix_dir, &self.scratch_dir.join("cache"), None, None, proxy, subcommand, args, ); let command_env = npm_command_env(&self.node); Ok(NpmCommand { path: self.npm.clone(), args: command_args, env: command_env, }) } async fn npm_package_installed_version( &self, local_package_directory: &Path, name: &str, ) -> Result> { read_package_installed_version(local_package_directory.join("node_modules"), name).await // todo: allow returning a globally installed version (requires callers not to hard-code the path) } } pub async fn read_package_installed_version( node_module_directory: PathBuf, name: &str, ) -> Result> { let package_json_path = node_module_directory.join(name).join("package.json"); let mut file = match fs::File::open(package_json_path).await { Ok(file) => file, Err(err) => { if err.kind() == io::ErrorKind::NotFound { return Ok(None); } Err(err)? } }; #[derive(Deserialize)] struct PackageJson { version: Version, } let mut contents = String::new(); file.read_to_string(&mut contents).await?; let package_json: PackageJson = serde_json::from_str(&contents)?; Ok(Some(package_json.version)) } pub async fn read_package_executable( node_module_directory: PathBuf, name: &str, ) -> Result { let package_directory = node_module_directory.join(name); #[derive(Deserialize)] #[serde(untagged)] enum Bin { Path(String), Named(HashMap), } #[derive(Deserialize)] struct PackageJson { bin: Option, } let package_json_path = package_directory.join("package.json"); let mut file = fs::File::open(&package_json_path) .await .with_context(|| format!("opening {}", package_json_path.display()))?; let mut contents = String::new(); file.read_to_string(&mut contents).await?; let package_json: PackageJson = serde_json::from_str(&contents) .with_context(|| format!("parsing {}", package_json_path.display()))?; let relative_path = match package_json.bin { Some(Bin::Path(path)) => path, Some(Bin::Named(bins)) => { let unscoped_name = name.rsplit('/').next().unwrap_or(name); let path = if bins.len() == 1 { bins.values().next() } else { bins.get(unscoped_name) }; path.with_context(|| { format!("npm package {name} declares no executable named {unscoped_name}") })? .clone() } None => bail!("npm package {name} declares no executable"), }; Ok(package_directory.join(relative_path)) } #[derive(Clone)] pub struct UnavailableNodeRuntime { error_message: Arc, } #[async_trait::async_trait] impl NodeRuntimeTrait for UnavailableNodeRuntime { fn boxed_clone(&self) -> Box { Box::new(self.clone()) } fn binary_path(&self) -> Result { bail!("{}", self.error_message) } async fn run_npm_subcommand( &self, _: Option<&Path>, _: Option<&Url>, _: &str, _: &[&str], ) -> anyhow::Result { bail!("{}", self.error_message) } async fn npm_command( &self, _: Option<&Path>, _proxy: Option<&Url>, _subcommand: &str, _args: &[&str], ) -> Result { bail!("{}", self.error_message) } async fn npm_package_installed_version( &self, _local_package_directory: &Path, _: &str, ) -> Result> { bail!("{}", self.error_message) } } fn proxy_argument(proxy: Option<&Url>) -> Option { let mut proxy = proxy.cloned()?; // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809` // NodeRuntime without environment information can not parse `localhost` // correctly. // TODO: map to `[::1]` if we are using ipv6 if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost")) { // When localhost is a valid Host, so is `127.0.0.1` let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST)); } Some(proxy.as_str().to_string()) } fn build_npm_command_args( entrypoint: Option<&Path>, prefix_dir: Option<&Path>, cache_dir: &Path, user_config: Option<&Path>, global_config: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> Vec { let mut command_args = Vec::new(); if let Some(entrypoint) = entrypoint { command_args.push(entrypoint.to_string_lossy().into_owned()); } if let Some(prefix_dir) = prefix_dir { command_args.push("--prefix".into()); command_args.push(prefix_dir.to_string_lossy().into_owned()); } command_args.push(subcommand.to_string()); command_args.push(format!("--cache={}", cache_dir.display())); if let Some(user_config) = user_config { command_args.push("--userconfig".into()); command_args.push(user_config.to_string_lossy().into_owned()); } if let Some(global_config) = global_config { command_args.push("--globalconfig".into()); command_args.push(global_config.to_string_lossy().into_owned()); } if let Some(proxy_arg) = proxy_argument(proxy) { command_args.push("--proxy".into()); command_args.push(proxy_arg); } command_args.extend(args.into_iter().map(|a| a.to_string())); command_args } pub fn npm_command_env(node_binary: &Path) -> HashMap { let mut command_env = HashMap::new(); let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default(); command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned()); if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) { if !node_ca_certs.is_empty() { command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs); } } #[cfg(windows)] { if let Some(val) = env::var("SYSTEMROOT") .context("Missing environment variable: SYSTEMROOT!") .log_err() { command_env.insert("SYSTEMROOT".into(), val); } if let Some(val) = env::var("ComSpec") .context("Missing environment variable: ComSpec!") .log_err() { command_env.insert("ComSpec".into(), val); } } command_env } #[cfg(test)] mod tests { use std::path::Path; use anyhow::{Result, bail}; use http_client::Url; use semver::{Version, VersionReq}; use super::{ NpmInfo, VersionStrategy, build_npm_command_args, deserialize_npm_info_from_response, proxy_argument, select_npm_package_version, should_install_npm_package_version, }; // Map localhost to 127.0.0.1 // NodeRuntime without environment information can not parse `localhost` correctly. #[test] fn test_proxy_argument_map_localhost_proxy() { const CASES: [(&str, &str); 4] = [ // Map localhost to 127.0.0.1 ("http://localhost:9090/", "http://127.0.0.1:9090/"), ("https://google.com/", "https://google.com/"), ( "http://username:password@proxy.thing.com:8080/", "http://username:password@proxy.thing.com:8080/", ), // Test when localhost is contained within a different part of the URL ( "http://username:localhost@localhost:8080/", "http://username:localhost@127.0.0.1:8080/", ), ]; for (proxy, mapped_proxy) in CASES { let proxy = Url::parse(proxy).unwrap(); let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly"); assert_eq!( proxy, mapped_proxy, "Incorrectly mapped localhost to 127.0.0.1" ); } } #[test] fn test_build_npm_command_args_inserts_prefix_before_subcommand() { let args = build_npm_command_args( None, Some(Path::new("/tmp/zed-prefix")), Path::new("/tmp/cache"), None, None, None, "exec", &["--yes", "--", "agent-package"], ); assert_eq!( args, vec![ "--prefix".to_string(), "/tmp/zed-prefix".to_string(), "exec".to_string(), "--cache=/tmp/cache".to_string(), "--yes".to_string(), "--".to_string(), "agent-package".to_string(), ] ); } #[test] fn test_build_npm_command_args_keeps_entrypoint_before_prefix() { let args = build_npm_command_args( Some(Path::new("/tmp/npm-cli.js")), Some(Path::new("/tmp/zed-prefix")), Path::new("/tmp/cache"), None, None, None, "exec", &["--yes"], ); assert_eq!( args, vec![ "/tmp/npm-cli.js".to_string(), "--prefix".to_string(), "/tmp/zed-prefix".to_string(), "exec".to_string(), "--cache=/tmp/cache".to_string(), "--yes".to_string(), ] ); } #[test] fn test_latest_version_strategy_accepts_newer_installed_versions() -> Result<()> { let target_version = Version::parse("2.0.0")?; assert!(!should_install_npm_package_version( &Version::parse("2.0.0")?, VersionStrategy::Latest(&target_version) )); assert!(should_install_npm_package_version( &Version::parse("1.0.0")?, VersionStrategy::Latest(&target_version) )); assert!(!should_install_npm_package_version( &Version::parse("3.0.0")?, VersionStrategy::Latest(&target_version) )); Ok(()) } #[test] fn test_select_npm_package_version_uses_dist_tag_without_before() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "3.0.0" }, "versions": ["1.0.0", "2.0.0", "3.0.0"], "time": { "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0": "2024-02-01T00:00:00.000Z", "3.0.0": "2024-03-01T00:00:00.000Z" } }"#, )?; assert_eq!( select_npm_package_version("test-package", info, None, None)?, Version::parse("3.0.0")? ); Ok(()) } #[test] fn test_npm_info_skips_non_string_time_entries() -> Result<()> { // Registries such as JFrog Artifactory include `"unpublished": null` in `time`; // parsing must tolerate this rather than rejecting the whole response. let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "2.0.0" }, "versions": ["1.0.0", "2.0.0"], "time": { "unpublished": null, "created": "2024-01-01T00:00:00.000Z", "modified": "2024-02-01T00:00:00.000Z", "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0": "2024-02-01T00:00:00.000Z" } }"#, )?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), None )?, Version::parse("2.0.0")? ); Ok(()) } #[test] fn test_select_npm_package_version_uses_latest_before_npm_before_config() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "3.0.0" }, "versions": ["1.0.0", "2.0.0", "3.0.0"], "time": { "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0": "2024-02-01T00:00:00.000Z", "3.0.0": "2024-03-01T00:00:00.000Z" } }"#, )?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), None )?, Version::parse("2.0.0")? ); Ok(()) } #[test] fn test_select_npm_package_version_keeps_allowed_latest_dist_tag() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "2.0.0" }, "versions": ["1.0.0", "2.0.0", "3.0.0"], "time": { "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0": "2024-02-01T00:00:00.000Z", "3.0.0": "2024-03-01T00:00:00.000Z" } }"#, )?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), None )?, Version::parse("2.0.0")? ); Ok(()) } #[test] fn test_select_npm_package_version_keeps_allowed_prerelease_latest_dist_tag() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "2.0.0-beta.1" }, "versions": ["1.0.0", "2.0.0-beta.1"], "time": { "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0-beta.1": "2024-02-01T00:00:00.000Z" } }"#, )?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), None )?, Version::parse("2.0.0-beta.1")? ); Ok(()) } #[test] fn test_select_npm_package_version_ignores_prereleases_before_cutoff() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "2.0.0" }, "versions": ["1.0.0", "2.0.0-beta.1", "2.0.0"], "time": { "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0-beta.1": "2024-02-01T00:00:00.000Z", "2.0.0": "2024-03-01T00:00:00.000Z" } }"#, )?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), None )?, Version::parse("1.0.0")? ); Ok(()) } #[test] fn test_select_npm_package_version_ignores_versions_above_latest_dist_tag() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "2.0.0" }, "versions": ["1.0.0", "2.0.0", "3.0.0"], "time": { "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0": "2024-03-01T00:00:00.000Z", "3.0.0": "2024-02-01T00:00:00.000Z" } }"#, )?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), None )?, Version::parse("1.0.0")? ); Ok(()) } #[test] fn test_select_npm_package_version_errors_when_no_version_matches_before() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "2.0.0" }, "versions": ["1.0.0", "2.0.0"], "time": { "1.0.0": "2024-01-01T00:00:00.000Z", "2.0.0": "2024-02-01T00:00:00.000Z" } }"#, )?; let Err(error) = select_npm_package_version( "test-package", info, Some("2023-12-01T00:00:00.000Z"), None, ) else { bail!("expected cutoff to reject all package versions"); }; assert_eq!( error.to_string(), "no version found for npm package test-package before 2023-12-01T00:00:00.000Z" ); Ok(()) } #[test] fn test_select_npm_package_version_selects_latest_matching_requirement() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "7.0.0" }, "versions": ["6.0.3", "7.0.0", "5.9.3", "6.0.2"] }"#, )?; let version_requirement = VersionReq::parse("^6")?; assert_eq!( select_npm_package_version("test-package", info, None, Some(&version_requirement))?, Version::parse("6.0.3")? ); Ok(()) } #[test] fn test_select_npm_package_version_applies_before_to_matching_versions() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "7.0.0" }, "versions": ["6.0.3", "7.0.0", "6.0.2"], "time": { "6.0.2": "2024-02-01T00:00:00.000Z", "6.0.3": "2024-03-01T00:00:00.000Z", "7.0.0": "2024-04-01T00:00:00.000Z" } }"#, )?; let version_requirement = VersionReq::parse("^6")?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), Some(&version_requirement), )?, Version::parse("6.0.2")? ); Ok(()) } #[test] fn test_select_npm_package_version_allows_requested_prerelease_before_cutoff() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "7.0.0" }, "versions": ["7.1.0-beta.1", "7.1.0-beta.2", "7.0.0"], "time": { "7.0.0": "2024-01-01T00:00:00.000Z", "7.1.0-beta.1": "2024-02-01T00:00:00.000Z", "7.1.0-beta.2": "2024-03-01T00:00:00.000Z" } }"#, )?; let version_requirement = VersionReq::parse(">=7.1.0-beta.1, <7.1.0")?; assert_eq!( select_npm_package_version( "test-package", info, Some("2024-02-15T00:00:00.000Z"), Some(&version_requirement), )?, Version::parse("7.1.0-beta.1")? ); Ok(()) } #[test] fn test_select_npm_package_version_errors_without_matching_version() -> Result<()> { let info: NpmInfo = serde_json::from_str( r#"{ "dist-tags": { "latest": "7.0.0" }, "versions": ["5.9.3", "7.0.0"] }"#, )?; let version_requirement = VersionReq::parse("^6")?; let error = select_npm_package_version("test-package", info, None, Some(&version_requirement)) .expect_err("expected version requirement to reject all package versions"); assert_eq!( error.to_string(), "no version found for npm package test-package" ); Ok(()) } #[test] fn test_pinned_version_strategy_replaces_different_installed_version() -> Result<()> { let pinned_version = Version::parse("6.0.3")?; assert!(!should_install_npm_package_version( &pinned_version, VersionStrategy::Pin(&pinned_version) )); assert!(should_install_npm_package_version( &Version::parse("7.0.0")?, VersionStrategy::Pin(&pinned_version) )); Ok(()) } #[test] fn test_deserialize_npm_info_npm11_format() -> Result<()> { let json = r#"{ "dist-tags": { "latest": "3.0.0" }, "versions": ["1.0.0", "2.0.0", "3.0.0"] }"#; let info = deserialize_npm_info_from_response(json.as_bytes())?; assert_eq!(info.dist_tags.latest, Some(Version::parse("3.0.0")?)); assert_eq!( info.versions, vec![ Version::parse("1.0.0")?, Version::parse("2.0.0")?, Version::parse("3.0.0")? ] ); Ok(()) } #[test] fn test_deserialize_npm_v12_format() -> Result<()> { let json = r#"[ { "dist-tags": { "latest": "3.0.0" }, "versions": ["1.0.0", "2.0.0", "3.0.0"] } ]"#; let info = deserialize_npm_info_from_response(json.as_bytes())?; assert_eq!(info.dist_tags.latest, Some(Version::parse("3.0.0")?)); assert_eq!( info.versions, vec![ Version::parse("1.0.0")?, Version::parse("2.0.0")?, Version::parse("3.0.0")? ] ); Ok(()) } }