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