Something went wrong. Try again.
fork of gpui
Something went wrong. Try again.
55 kB · 1660 lines
Rust
at master
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661use 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 .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<String, String>, before: &DateTime<Utc>, allow_prereleases: bool,) -> Result<bool> { 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<String, String>, before: &DateTime<Utc>,) -> Result<bool> { 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<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, prefix_dir: Option<&Path>, 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?; } 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<OsString> { 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<dyn NodeRuntimeTrait> { Box::new(self.clone()) }
fn binary_path(&self) -> Result<PathBuf> { 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<Output> { 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<NpmCommand> { 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<Option<Version>> { 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<Self> { 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<Self, DetectError> { 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<dyn NodeRuntimeTrait> { Box::new(self.clone()) }
fn binary_path(&self) -> Result<PathBuf> { Ok(self.node.clone()) }
async fn run_npm_subcommand( &self, directory: Option<&Path>, proxy: Option<&Url>, subcommand: &str, args: &[&str], ) -> anyhow::Result<Output> { 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<NpmCommand> { 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<Option<Version>> { 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<Option<Version>> { 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<PathBuf> { let package_directory = node_module_directory.join(name);
#[derive(Deserialize)] #[serde(untagged)] enum Bin { Path(String), Named(HashMap<String, String>), }
#[derive(Deserialize)] struct PackageJson { bin: Option<Bin>, }
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<String>,}
#[async_trait::async_trait]impl NodeRuntimeTrait for UnavailableNodeRuntime { fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> { Box::new(self.clone()) } fn binary_path(&self) -> Result<PathBuf> { bail!("{}", self.error_message) }
async fn run_npm_subcommand( &self, _: Option<&Path>, _: Option<&Url>, _: &str, _: &[&str], ) -> anyhow::Result<Output> { bail!("{}", self.error_message) }
async fn npm_command( &self, _: Option<&Path>, _proxy: Option<&Url>, _subcommand: &str, _args: &[&str], ) -> Result<NpmCommand> { bail!("{}", self.error_message) }
async fn npm_package_installed_version( &self, _local_package_directory: &Path, _: &str, ) -> Result<Option<Version>> { bail!("{}", self.error_message) }}
fn proxy_argument(proxy: Option<&Url>) -> Option<String> { 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<String> { 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<String, String> { 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(()) }}