1use anyhow::{Context as _, Result, anyhow, bail};
2use async_compression::futures::bufread::GzipDecoder;
3use async_tar::Archive;
4use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};
5use http_client::{Host, HttpClient, Url};
6use log::Level;
7use semver::Version;
8use serde::Deserialize;
9use smol::io::BufReader;
10use smol::{fs, lock::Mutex};
11use std::collections::HashMap;
12use std::fmt::Display;
13use std::{
14 env::{self, consts},
15 ffi::OsString,
16 io,
17 net::{IpAddr, Ipv4Addr},
18 path::{Path, PathBuf},
19 process::Output,
20 sync::Arc,
21};
22use util::ResultExt;
23use util::archive::extract_zip;
24
25const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS";
26
27#[derive(Clone, Debug, Default, Eq, PartialEq)]
28pub struct NodeBinaryOptions {
29 pub allow_path_lookup: bool,
30 pub allow_binary_download: bool,
31 pub use_paths: Option<(PathBuf, PathBuf)>,
32}
33
34/// Use this when you need to launch npm as a long-lived process (for example, an agent server),
35/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.
36#[derive(Clone, Debug)]
37pub struct NpmCommand {
38 pub path: PathBuf,
39 pub args: Vec<String>,
40 pub env: HashMap<String, String>,
41}
42
43pub enum VersionStrategy<'a> {
44 /// Install if current version doesn't match pinned version
45 Pin(&'a Version),
46 /// Install if current version is older than latest version
47 Latest(&'a Version),
48}
49
50#[derive(Clone)]
51pub struct NodeRuntime(Arc<Mutex<NodeRuntimeState>>);
52
53struct NodeRuntimeState {
54 http: Arc<dyn HttpClient>,
55 instance: Option<Box<dyn NodeRuntimeTrait>>,
56 last_options: Option<NodeBinaryOptions>,
57 options: watch::Receiver<Option<NodeBinaryOptions>>,
58 shell_env_loaded: Shared<oneshot::Receiver<()>>,
59}
60
61impl NodeRuntime {
62 pub fn new(
63 http: Arc<dyn HttpClient>,
64 shell_env_loaded: Option<oneshot::Receiver<()>>,
65 options: watch::Receiver<Option<NodeBinaryOptions>>,
66 ) -> Self {
67 NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
68 http,
69 instance: None,
70 last_options: None,
71 options,
72 shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(),
73 })))
74 }
75
76 pub fn unavailable() -> Self {
77 NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
78 http: Arc::new(http_client::BlockedHttpClient),
79 instance: None,
80 last_options: None,
81 options: watch::channel(Some(NodeBinaryOptions::default())).1,
82 shell_env_loaded: oneshot::channel().1.shared(),
83 })))
84 }
85
86 async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {
87 let mut state = self.0.lock().await;
88
89 let options = loop {
90 if let Some(options) = state.options.borrow().as_ref() {
91 break options.clone();
92 }
93 match state.options.changed().await {
94 Ok(()) => {}
95 // failure case not cached
96 Err(err) => {
97 return Box::new(UnavailableNodeRuntime {
98 error_message: err.to_string().into(),
99 }) as Box<dyn NodeRuntimeTrait>;
100 }
101 }
102 };
103
104 if state.last_options.as_ref() != Some(&options) {
105 state.instance.take();
106 }
107 if let Some(instance) = state.instance.as_ref() {
108 return instance.boxed_clone();
109 }
110
111 if let Some((node, npm)) = options.use_paths.as_ref() {
112 let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await {
113 Ok(instance) => {
114 log::info!("using Node.js from `node.path` in settings: {:?}", instance);
115 Box::new(instance)
116 }
117 Err(err) => {
118 // failure case not cached, since it's cheap to check again
119 return Box::new(UnavailableNodeRuntime {
120 error_message: format!(
121 "failure checking Node.js from `node.path` in settings ({}): {:?}",
122 node.display(),
123 err
124 )
125 .into(),
126 });
127 }
128 };
129 state.instance = Some(instance.boxed_clone());
130 state.last_options = Some(options);
131 return instance;
132 }
133
134 let system_node_error = if options.allow_path_lookup {
135 state.shell_env_loaded.clone().await.ok();
136 match SystemNodeRuntime::detect().await {
137 Ok(instance) => {
138 log::info!("using Node.js found on PATH: {:?}", instance);
139 state.instance = Some(instance.boxed_clone());
140 state.last_options = Some(options);
141 return Box::new(instance) as Box<dyn NodeRuntimeTrait>;
142 }
143 Err(err) => Some(err),
144 }
145 } else {
146 None
147 };
148
149 let instance = if options.allow_binary_download {
150 let (log_level, why_using_managed) = match system_node_error {
151 Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()),
152 Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()),
153 None => (
154 Level::Info,
155 "`node.ignore_system_version` is `true` in settings".to_string(),
156 ),
157 };
158 match ManagedNodeRuntime::install_if_needed(&state.http).await {
159 Ok(instance) => {
160 log::log!(
161 log_level,
162 "using Zed managed Node.js at {} since {}",
163 instance.installation_path.display(),
164 why_using_managed
165 );
166 Box::new(instance) as Box<dyn NodeRuntimeTrait>
167 }
168 Err(err) => {
169 // failure case is cached, since downloading + installing may be expensive. The
170 // downside of this is that it may fail due to an intermittent network issue.
171 //
172 // TODO: Have `install_if_needed` indicate which failure cases are retryable
173 // and/or have shared tracking of when internet is available.
174 Box::new(UnavailableNodeRuntime {
175 error_message: format!(
176 "failure while downloading and/or installing Zed managed Node.js, \
177 restart Zed to retry: {}",
178 err
179 )
180 .into(),
181 }) as Box<dyn NodeRuntimeTrait>
182 }
183 }
184 } else if let Some(system_node_error) = system_node_error {
185 // failure case not cached, since it's cheap to check again
186 //
187 // TODO: When support is added for setting `options.allow_binary_download`, update this
188 // error message.
189 return Box::new(UnavailableNodeRuntime {
190 error_message: format!(
191 "failure while checking system Node.js from PATH: {}",
192 system_node_error
193 )
194 .into(),
195 });
196 } else {
197 // failure case is cached because it will always happen with these options
198 //
199 // TODO: When support is added for setting `options.allow_binary_download`, update this
200 // error message.
201 Box::new(UnavailableNodeRuntime {
202 error_message: "`node` settings do not allow any way to use Node.js"
203 .to_string()
204 .into(),
205 })
206 };
207
208 state.instance = Some(instance.boxed_clone());
209 state.last_options = Some(options);
210 instance
211 }
212
213 pub async fn binary_path(&self) -> Result<PathBuf> {
214 self.instance().await.binary_path()
215 }
216
217 pub async fn run_npm_subcommand(
218 &self,
219 directory: Option<&Path>,
220 subcommand: &str,
221 args: &[&str],
222 ) -> Result<Output> {
223 let http = self.0.lock().await.http.clone();
224 self.instance()
225 .await
226 .run_npm_subcommand(directory, http.proxy(), subcommand, args)
227 .await
228 }
229
230 pub async fn npm_package_installed_version(
231 &self,
232 local_package_directory: &Path,
233 name: &str,
234 ) -> Result<Option<Version>> {
235 self.instance()
236 .await
237 .npm_package_installed_version(local_package_directory, name)
238 .await
239 }
240
241 pub async fn npm_command(
242 &self,
243 prefix_dir: Option<&Path>,
244 subcommand: &str,
245 args: &[&str],
246 ) -> Result<NpmCommand> {
247 let http = self.0.lock().await.http.clone();
248 self.instance()
249 .await
250 .npm_command(prefix_dir, http.proxy(), subcommand, args)
251 .await
252 }
253
254 pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
255 let http = self.0.lock().await.http.clone();
256 let output = self
257 .instance()
258 .await
259 .run_npm_subcommand(
260 None,
261 http.proxy(),
262 "info",
263 &[
264 name,
265 "--json",
266 "--fetch-retry-mintimeout",
267 "2000",
268 "--fetch-retry-maxtimeout",
269 "5000",
270 "--fetch-timeout",
271 "5000",
272 ],
273 )
274 .await?;
275
276 let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?;
277 info.dist_tags
278 .latest
279 .or_else(|| info.versions.pop())
280 .with_context(|| format!("no version found for npm package {name}"))
281 }
282
283 pub async fn npm_install_packages(
284 &self,
285 directory: &Path,
286 packages: &[(&str, &str)],
287 ) -> Result<()> {
288 if packages.is_empty() {
289 return Ok(());
290 }
291
292 let packages: Vec<_> = packages
293 .iter()
294 .map(|(name, version)| format!("{name}@{version}"))
295 .collect();
296
297 let arguments: Vec<_> = packages
298 .iter()
299 .map(|p| p.as_str())
300 .chain([
301 "--save-exact",
302 "--fetch-retry-mintimeout",
303 "2000",
304 "--fetch-retry-maxtimeout",
305 "5000",
306 "--fetch-timeout",
307 "5000",
308 ])
309 .collect();
310
311 // This is also wrong because the directory is wrong.
312 self.run_npm_subcommand(Some(directory), "install", &arguments)
313 .await?;
314 Ok(())
315 }
316
317 pub async fn should_install_npm_package(
318 &self,
319 package_name: &str,
320 local_executable_path: &Path,
321 local_package_directory: &Path,
322 version_strategy: VersionStrategy<'_>,
323 ) -> bool {
324 // In the case of the local system not having the package installed,
325 // or in the instances where we fail to parse package.json data,
326 // we attempt to install the package.
327 if fs::metadata(local_executable_path).await.is_err() {
328 return true;
329 }
330
331 let Some(installed_version) = self
332 .npm_package_installed_version(local_package_directory, package_name)
333 .await
334 .log_err()
335 .flatten()
336 else {
337 return true;
338 };
339
340 match version_strategy {
341 VersionStrategy::Pin(pinned_version) => &installed_version != pinned_version,
342 VersionStrategy::Latest(latest_version) => &installed_version < latest_version,
343 }
344 }
345}
346
347enum ArchiveType {
348 TarGz,
349 Zip,
350}
351
352#[derive(Debug, Deserialize)]
353#[serde(rename_all = "kebab-case")]
354pub struct NpmInfo {
355 #[serde(default)]
356 dist_tags: NpmInfoDistTags,
357 versions: Vec<Version>,
358}
359
360#[derive(Debug, Deserialize, Default)]
361pub struct NpmInfoDistTags {
362 latest: Option<Version>,
363}
364
365#[async_trait::async_trait]
366trait NodeRuntimeTrait: Send + Sync {
367 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait>;
368 fn binary_path(&self) -> Result<PathBuf>;
369
370 async fn run_npm_subcommand(
371 &self,
372 directory: Option<&Path>,
373 proxy: Option<&Url>,
374 subcommand: &str,
375 args: &[&str],
376 ) -> Result<Output>;
377
378 async fn npm_command(
379 &self,
380 prefix_dir: Option<&Path>,
381 proxy: Option<&Url>,
382 subcommand: &str,
383 args: &[&str],
384 ) -> Result<NpmCommand>;
385
386 async fn npm_package_installed_version(
387 &self,
388 local_package_directory: &Path,
389 name: &str,
390 ) -> Result<Option<Version>>;
391}
392
393#[derive(Clone)]
394struct ManagedNodeRuntime {
395 installation_path: PathBuf,
396}
397
398impl ManagedNodeRuntime {
399 const VERSION: &str = "v24.11.0";
400
401 #[cfg(not(windows))]
402 const NODE_PATH: &str = "bin/node";
403 #[cfg(windows)]
404 const NODE_PATH: &str = "node.exe";
405
406 #[cfg(not(windows))]
407 const NPM_PATH: &str = "bin/npm";
408 #[cfg(windows)]
409 const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";
410
411 async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Self> {
412 log::info!("Node runtime install_if_needed");
413
414 let os = match consts::OS {
415 "macos" => "darwin",
416 "linux" => "linux",
417 "windows" => "win",
418 other => bail!("Running on unsupported os: {other}"),
419 };
420
421 let arch = match consts::ARCH {
422 "x86_64" => "x64",
423 "aarch64" => "arm64",
424 other => bail!("Running on unsupported architecture: {other}"),
425 };
426
427 let version = Self::VERSION;
428 let folder_name = format!("node-{version}-{os}-{arch}");
429 let node_containing_dir = paths::data_dir().join("node");
430 let node_dir = node_containing_dir.join(folder_name);
431 let node_binary = node_dir.join(Self::NODE_PATH);
432 let npm_file = node_dir.join(Self::NPM_PATH);
433 let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
434
435 let valid = if fs::metadata(&node_binary).await.is_ok() {
436 let result = util::command::new_command(&node_binary)
437 .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
438 .arg(npm_file)
439 .arg("--version")
440 .args(["--cache".into(), node_dir.join("cache")])
441 .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
442 .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
443 .output()
444 .await;
445 match result {
446 Ok(output) => {
447 if output.status.success() {
448 true
449 } else {
450 log::warn!(
451 "Zed managed Node.js binary at {} failed check with output: {:?}",
452 node_binary.display(),
453 output
454 );
455 false
456 }
457 }
458 Err(err) => {
459 log::warn!(
460 "Zed managed Node.js binary at {} failed check, so re-downloading it. \
461 Error: {}",
462 node_binary.display(),
463 err
464 );
465 false
466 }
467 }
468 } else {
469 false
470 };
471
472 if !valid {
473 _ = fs::remove_dir_all(&node_containing_dir).await;
474 fs::create_dir(&node_containing_dir)
475 .await
476 .context("error creating node containing dir")?;
477
478 let archive_type = match consts::OS {
479 "macos" | "linux" => ArchiveType::TarGz,
480 "windows" => ArchiveType::Zip,
481 other => bail!("Running on unsupported os: {other}"),
482 };
483
484 let version = Self::VERSION;
485 let file_name = format!(
486 "node-{version}-{os}-{arch}.{extension}",
487 extension = match archive_type {
488 ArchiveType::TarGz => "tar.gz",
489 ArchiveType::Zip => "zip",
490 }
491 );
492
493 let url = format!("https://nodejs.org/dist/{version}/{file_name}");
494 log::info!("Downloading Node.js binary from {url}");
495 let mut response = http
496 .get(&url, Default::default(), true)
497 .await
498 .context("error downloading Node binary tarball")?;
499 log::info!("Download of Node.js complete, extracting...");
500
501 let body = response.body_mut();
502 match archive_type {
503 ArchiveType::TarGz => {
504 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
505 let archive = Archive::new(decompressed_bytes);
506 archive.unpack(&node_containing_dir).await?;
507 }
508 ArchiveType::Zip => extract_zip(&node_containing_dir, body).await?,
509 }
510 log::info!("Extracted Node.js to {}", node_containing_dir.display())
511 }
512
513 // Note: Not in the `if !valid {}` so we can populate these for existing installations
514 _ = fs::create_dir(node_dir.join("cache")).await;
515 _ = fs::write(node_dir.join("blank_user_npmrc"), []).await;
516 _ = fs::write(node_dir.join("blank_global_npmrc"), []).await;
517
518 anyhow::Ok(ManagedNodeRuntime {
519 installation_path: node_dir,
520 })
521 }
522}
523
524fn path_with_node_binary_prepended(node_binary: &Path) -> Option<OsString> {
525 let existing_path = env::var_os("PATH");
526 let node_bin_dir = node_binary.parent().map(|dir| dir.as_os_str());
527 match (existing_path, node_bin_dir) {
528 (Some(existing_path), Some(node_bin_dir)) => {
529 if let Ok(joined) = env::join_paths(
530 [PathBuf::from(node_bin_dir)]
531 .into_iter()
532 .chain(env::split_paths(&existing_path)),
533 ) {
534 Some(joined)
535 } else {
536 Some(existing_path)
537 }
538 }
539 (Some(existing_path), None) => Some(existing_path),
540 (None, Some(node_bin_dir)) => Some(node_bin_dir.to_owned()),
541 _ => None,
542 }
543}
544
545#[async_trait::async_trait]
546impl NodeRuntimeTrait for ManagedNodeRuntime {
547 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
548 Box::new(self.clone())
549 }
550
551 fn binary_path(&self) -> Result<PathBuf> {
552 Ok(self.installation_path.join(Self::NODE_PATH))
553 }
554
555 async fn run_npm_subcommand(
556 &self,
557 directory: Option<&Path>,
558 proxy: Option<&Url>,
559 subcommand: &str,
560 args: &[&str],
561 ) -> Result<Output> {
562 let attempt = || async {
563 let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
564 let mut command = util::command::new_command(npm_command.path);
565 command.args(npm_command.args);
566 command.envs(npm_command.env);
567 if let Some(directory) = directory {
568 command.current_dir(directory);
569 }
570 command.output().await.map_err(|e| anyhow!("{e}"))
571 };
572
573 let mut output = attempt().await;
574 if output.is_err() {
575 output = attempt().await;
576 anyhow::ensure!(
577 output.is_ok(),
578 "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}",
579 output.err()
580 );
581 }
582
583 if let Ok(output) = &output {
584 anyhow::ensure!(
585 output.status.success(),
586 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
587 String::from_utf8_lossy(&output.stdout),
588 String::from_utf8_lossy(&output.stderr)
589 );
590 }
591
592 output.map_err(|e| anyhow!("{e}"))
593 }
594
595 async fn npm_command(
596 &self,
597 prefix_dir: Option<&Path>,
598 proxy: Option<&Url>,
599 subcommand: &str,
600 args: &[&str],
601 ) -> Result<NpmCommand> {
602 let node_binary = self.installation_path.join(Self::NODE_PATH);
603 let npm_file = self.installation_path.join(Self::NPM_PATH);
604
605 anyhow::ensure!(
606 smol::fs::metadata(&node_binary).await.is_ok(),
607 "missing node binary file"
608 );
609 anyhow::ensure!(
610 smol::fs::metadata(&npm_file).await.is_ok(),
611 "missing npm file"
612 );
613
614 let command_args = build_npm_command_args(
615 Some(&npm_file),
616 prefix_dir,
617 &self.installation_path.join("cache"),
618 Some(&self.installation_path.join("blank_user_npmrc")),
619 Some(&self.installation_path.join("blank_global_npmrc")),
620 proxy,
621 subcommand,
622 args,
623 );
624 let command_env = npm_command_env(Some(&node_binary));
625
626 Ok(NpmCommand {
627 path: node_binary,
628 args: command_args,
629 env: command_env,
630 })
631 }
632
633 async fn npm_package_installed_version(
634 &self,
635 local_package_directory: &Path,
636 name: &str,
637 ) -> Result<Option<Version>> {
638 read_package_installed_version(local_package_directory.join("node_modules"), name).await
639 }
640}
641
642#[derive(Debug, Clone)]
643pub struct SystemNodeRuntime {
644 node: PathBuf,
645 npm: PathBuf,
646 global_node_modules: PathBuf,
647 scratch_dir: PathBuf,
648}
649
650impl SystemNodeRuntime {
651 const MIN_VERSION: semver::Version = Version::new(22, 0, 0);
652 async fn new(node: PathBuf, npm: PathBuf) -> Result<Self> {
653 let output = util::command::new_command(&node)
654 .arg("--version")
655 .output()
656 .await
657 .with_context(|| format!("running node from {:?}", node))?;
658 if !output.status.success() {
659 anyhow::bail!(
660 "failed to run node --version. stdout: {}, stderr: {}",
661 String::from_utf8_lossy(&output.stdout),
662 String::from_utf8_lossy(&output.stderr),
663 );
664 }
665 let version_str = String::from_utf8_lossy(&output.stdout);
666 let version = semver::Version::parse(version_str.trim().trim_start_matches('v'))?;
667 if version < Self::MIN_VERSION {
668 anyhow::bail!(
669 "node at {} is too old. want: {}, got: {}",
670 node.to_string_lossy(),
671 Self::MIN_VERSION,
672 version
673 )
674 }
675
676 let scratch_dir = paths::data_dir().join("node");
677 fs::create_dir(&scratch_dir).await.ok();
678 fs::create_dir(scratch_dir.join("cache")).await.ok();
679
680 let mut this = Self {
681 node,
682 npm,
683 global_node_modules: PathBuf::default(),
684 scratch_dir,
685 };
686 let output = this.run_npm_subcommand(None, None, "root", &["-g"]).await?;
687 this.global_node_modules =
688 PathBuf::from(String::from_utf8_lossy(&output.stdout).to_string());
689
690 Ok(this)
691 }
692
693 async fn detect() -> std::result::Result<Self, DetectError> {
694 let node = which::which("node").map_err(DetectError::NotInPath)?;
695 let npm = which::which("npm").map_err(DetectError::NotInPath)?;
696 Self::new(node, npm).await.map_err(DetectError::Other)
697 }
698}
699
700enum DetectError {
701 NotInPath(which::Error),
702 Other(anyhow::Error),
703}
704
705impl Display for DetectError {
706 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
707 match self {
708 DetectError::NotInPath(err) => {
709 write!(f, "system Node.js wasn't found on PATH: {}", err)
710 }
711 DetectError::Other(err) => {
712 write!(f, "checking system Node.js failed with error: {}", err)
713 }
714 }
715 }
716}
717
718#[async_trait::async_trait]
719impl NodeRuntimeTrait for SystemNodeRuntime {
720 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
721 Box::new(self.clone())
722 }
723
724 fn binary_path(&self) -> Result<PathBuf> {
725 Ok(self.node.clone())
726 }
727
728 async fn run_npm_subcommand(
729 &self,
730 directory: Option<&Path>,
731 proxy: Option<&Url>,
732 subcommand: &str,
733 args: &[&str],
734 ) -> anyhow::Result<Output> {
735 let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
736 let mut command = util::command::new_command(npm_command.path);
737 command.args(npm_command.args);
738 command.envs(npm_command.env);
739 if let Some(directory) = directory {
740 command.current_dir(directory);
741 }
742 let output = command.output().await?;
743 anyhow::ensure!(
744 output.status.success(),
745 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
746 String::from_utf8_lossy(&output.stdout),
747 String::from_utf8_lossy(&output.stderr)
748 );
749 Ok(output)
750 }
751
752 async fn npm_command(
753 &self,
754 prefix_dir: Option<&Path>,
755 proxy: Option<&Url>,
756 subcommand: &str,
757 args: &[&str],
758 ) -> Result<NpmCommand> {
759 let command_args = build_npm_command_args(
760 None,
761 prefix_dir,
762 &self.scratch_dir.join("cache"),
763 None,
764 None,
765 proxy,
766 subcommand,
767 args,
768 );
769 let command_env = npm_command_env(Some(&self.node));
770
771 Ok(NpmCommand {
772 path: self.npm.clone(),
773 args: command_args,
774 env: command_env,
775 })
776 }
777
778 async fn npm_package_installed_version(
779 &self,
780 local_package_directory: &Path,
781 name: &str,
782 ) -> Result<Option<Version>> {
783 read_package_installed_version(local_package_directory.join("node_modules"), name).await
784 // todo: allow returning a globally installed version (requires callers not to hard-code the path)
785 }
786}
787
788pub async fn read_package_installed_version(
789 node_module_directory: PathBuf,
790 name: &str,
791) -> Result<Option<Version>> {
792 let package_json_path = node_module_directory.join(name).join("package.json");
793
794 let mut file = match fs::File::open(package_json_path).await {
795 Ok(file) => file,
796 Err(err) => {
797 if err.kind() == io::ErrorKind::NotFound {
798 return Ok(None);
799 }
800
801 Err(err)?
802 }
803 };
804
805 #[derive(Deserialize)]
806 struct PackageJson {
807 version: Version,
808 }
809
810 let mut contents = String::new();
811 file.read_to_string(&mut contents).await?;
812 let package_json: PackageJson = serde_json::from_str(&contents)?;
813 Ok(Some(package_json.version))
814}
815
816#[derive(Clone)]
817pub struct UnavailableNodeRuntime {
818 error_message: Arc<String>,
819}
820
821#[async_trait::async_trait]
822impl NodeRuntimeTrait for UnavailableNodeRuntime {
823 fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
824 Box::new(self.clone())
825 }
826 fn binary_path(&self) -> Result<PathBuf> {
827 bail!("{}", self.error_message)
828 }
829
830 async fn run_npm_subcommand(
831 &self,
832 _: Option<&Path>,
833 _: Option<&Url>,
834 _: &str,
835 _: &[&str],
836 ) -> anyhow::Result<Output> {
837 bail!("{}", self.error_message)
838 }
839
840 async fn npm_command(
841 &self,
842 _: Option<&Path>,
843 _proxy: Option<&Url>,
844 _subcommand: &str,
845 _args: &[&str],
846 ) -> Result<NpmCommand> {
847 bail!("{}", self.error_message)
848 }
849
850 async fn npm_package_installed_version(
851 &self,
852 _local_package_directory: &Path,
853 _: &str,
854 ) -> Result<Option<Version>> {
855 bail!("{}", self.error_message)
856 }
857}
858
859fn proxy_argument(proxy: Option<&Url>) -> Option<String> {
860 let mut proxy = proxy.cloned()?;
861 // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
862 // NodeRuntime without environment information can not parse `localhost`
863 // correctly.
864 // TODO: map to `[::1]` if we are using ipv6
865 if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
866 {
867 // When localhost is a valid Host, so is `127.0.0.1`
868 let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
869 }
870
871 Some(proxy.as_str().to_string())
872}
873
874fn build_npm_command_args(
875 entrypoint: Option<&Path>,
876 prefix_dir: Option<&Path>,
877 cache_dir: &Path,
878 user_config: Option<&Path>,
879 global_config: Option<&Path>,
880 proxy: Option<&Url>,
881 subcommand: &str,
882 args: &[&str],
883) -> Vec<String> {
884 let mut command_args = Vec::new();
885 if let Some(entrypoint) = entrypoint {
886 command_args.push(entrypoint.to_string_lossy().into_owned());
887 }
888 if let Some(prefix_dir) = prefix_dir {
889 command_args.push("--prefix".into());
890 command_args.push(prefix_dir.to_string_lossy().into_owned());
891 }
892 command_args.push(subcommand.to_string());
893 command_args.push(format!("--cache={}", cache_dir.display()));
894 if let Some(user_config) = user_config {
895 command_args.push("--userconfig".into());
896 command_args.push(user_config.to_string_lossy().into_owned());
897 }
898 if let Some(global_config) = global_config {
899 command_args.push("--globalconfig".into());
900 command_args.push(global_config.to_string_lossy().into_owned());
901 }
902 if let Some(proxy_arg) = proxy_argument(proxy) {
903 command_args.push("--proxy".into());
904 command_args.push(proxy_arg);
905 }
906 command_args.extend(args.into_iter().map(|a| a.to_string()));
907 command_args
908}
909
910fn npm_command_env(node_binary: Option<&Path>) -> HashMap<String, String> {
911 let mut command_env = HashMap::new();
912 if let Some(node_binary) = node_binary {
913 let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default();
914 command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned());
915 }
916
917 if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) {
918 if !node_ca_certs.is_empty() {
919 command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs);
920 }
921 }
922
923 #[cfg(windows)]
924 {
925 if let Some(val) = env::var("SYSTEMROOT")
926 .context("Missing environment variable: SYSTEMROOT!")
927 .log_err()
928 {
929 command_env.insert("SYSTEMROOT".into(), val);
930 }
931 if let Some(val) = env::var("ComSpec")
932 .context("Missing environment variable: ComSpec!")
933 .log_err()
934 {
935 command_env.insert("ComSpec".into(), val);
936 }
937 }
938
939 command_env
940}
941
942#[cfg(test)]
943mod tests {
944 use std::path::Path;
945
946 use http_client::Url;
947
948 use super::{build_npm_command_args, proxy_argument};
949
950 // Map localhost to 127.0.0.1
951 // NodeRuntime without environment information can not parse `localhost` correctly.
952 #[test]
953 fn test_proxy_argument_map_localhost_proxy() {
954 const CASES: [(&str, &str); 4] = [
955 // Map localhost to 127.0.0.1
956 ("http://localhost:9090/", "http://127.0.0.1:9090/"),
957 ("https://google.com/", "https://google.com/"),
958 (
959 "http://username:password@proxy.thing.com:8080/",
960 "http://username:password@proxy.thing.com:8080/",
961 ),
962 // Test when localhost is contained within a different part of the URL
963 (
964 "http://username:localhost@localhost:8080/",
965 "http://username:localhost@127.0.0.1:8080/",
966 ),
967 ];
968
969 for (proxy, mapped_proxy) in CASES {
970 let proxy = Url::parse(proxy).unwrap();
971 let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly");
972 assert_eq!(
973 proxy, mapped_proxy,
974 "Incorrectly mapped localhost to 127.0.0.1"
975 );
976 }
977 }
978
979 #[test]
980 fn test_build_npm_command_args_inserts_prefix_before_subcommand() {
981 let args = build_npm_command_args(
982 None,
983 Some(Path::new("/tmp/zed-prefix")),
984 Path::new("/tmp/cache"),
985 None,
986 None,
987 None,
988 "exec",
989 &["--yes", "--", "agent-package"],
990 );
991
992 assert_eq!(
993 args,
994 vec![
995 "--prefix".to_string(),
996 "/tmp/zed-prefix".to_string(),
997 "exec".to_string(),
998 "--cache=/tmp/cache".to_string(),
999 "--yes".to_string(),
1000 "--".to_string(),
1001 "agent-package".to_string(),
1002 ]
1003 );
1004 }
1005
1006 #[test]
1007 fn test_build_npm_command_args_keeps_entrypoint_before_prefix() {
1008 let args = build_npm_command_args(
1009 Some(Path::new("/tmp/npm-cli.js")),
1010 Some(Path::new("/tmp/zed-prefix")),
1011 Path::new("/tmp/cache"),
1012 None,
1013 None,
1014 None,
1015 "exec",
1016 &["--yes"],
1017 );
1018
1019 assert_eq!(
1020 args,
1021 vec![
1022 "/tmp/npm-cli.js".to_string(),
1023 "--prefix".to_string(),
1024 "/tmp/zed-prefix".to_string(),
1025 "exec".to_string(),
1026 "--cache=/tmp/cache".to_string(),
1027 "--yes".to_string(),
1028 ]
1029 );
1030 }
1031}