1use anyhow::{anyhow, bail, Context, Result};
2use async_compression::futures::bufread::GzipDecoder;
3use async_tar::Archive;
4use futures::{future::Shared, FutureExt};
5use gpui::{executor::Background, Task};
6use parking_lot::Mutex;
7use serde::Deserialize;
8use smol::{fs, io::BufReader, process::Command};
9use std::{
10 env::consts,
11 path::{Path, PathBuf},
12 sync::Arc,
13};
14use util::http::HttpClient;
15
16const VERSION: &str = "v18.15.0";
17
18#[derive(Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub struct NpmInfo {
21 #[serde(default)]
22 dist_tags: NpmInfoDistTags,
23 versions: Vec<String>,
24}
25
26#[derive(Deserialize, Default)]
27pub struct NpmInfoDistTags {
28 latest: Option<String>,
29}
30
31pub struct NodeRuntime {
32 http: Arc<dyn HttpClient>,
33 background: Arc<Background>,
34 installation_path: Mutex<Option<Shared<Task<Result<PathBuf, Arc<anyhow::Error>>>>>>,
35}
36
37impl NodeRuntime {
38 pub fn new(http: Arc<dyn HttpClient>, background: Arc<Background>) -> Arc<NodeRuntime> {
39 Arc::new(NodeRuntime {
40 http,
41 background,
42 installation_path: Mutex::new(None),
43 })
44 }
45
46 pub async fn binary_path(&self) -> Result<PathBuf> {
47 let installation_path = self.install_if_needed().await?;
48 Ok(installation_path.join("bin/node"))
49 }
50
51 pub async fn run_npm_subcommand(
52 &self,
53 directory: &Path,
54 subcommand: &str,
55 args: &[&str],
56 ) -> Result<()> {
57 let installation_path = self.install_if_needed().await?;
58 let node_binary = installation_path.join("bin/node");
59 let npm_file = installation_path.join("bin/npm");
60
61 let output = Command::new(node_binary)
62 .arg(npm_file)
63 .arg(subcommand)
64 .args(args)
65 .current_dir(directory)
66 .output()
67 .await?;
68
69 if !output.status.success() {
70 return Err(anyhow!(
71 "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
72 String::from_utf8_lossy(&output.stdout),
73 String::from_utf8_lossy(&output.stderr)
74 ));
75 }
76
77 Ok(())
78 }
79
80 pub async fn npm_package_latest_version(&self, name: &str) -> Result<String> {
81 let installation_path = self.install_if_needed().await?;
82 let node_binary = installation_path.join("bin/node");
83 let npm_file = installation_path.join("bin/npm");
84
85 let output = Command::new(node_binary)
86 .arg(npm_file)
87 .args(["-fetch-retry-mintimeout", "2000"])
88 .args(["-fetch-retry-maxtimeout", "5000"])
89 .args(["-fetch-timeout", "5000"])
90 .args(["info", name, "--json"])
91 .output()
92 .await
93 .context("failed to run npm info")?;
94
95 if !output.status.success() {
96 return Err(anyhow!(
97 "failed to execute npm info:\nstdout: {:?}\nstderr: {:?}",
98 String::from_utf8_lossy(&output.stdout),
99 String::from_utf8_lossy(&output.stderr)
100 ));
101 }
102
103 let mut info: NpmInfo = serde_json::from_slice(&output.stdout)?;
104 info.dist_tags
105 .latest
106 .or_else(|| info.versions.pop())
107 .ok_or_else(|| anyhow!("no version found for npm package {}", name))
108 }
109
110 pub async fn npm_install_packages(
111 &self,
112 directory: &Path,
113 packages: impl IntoIterator<Item = (&str, &str)>,
114 ) -> Result<()> {
115 let installation_path = self.install_if_needed().await?;
116 let node_binary = installation_path.join("bin/node");
117 let npm_file = installation_path.join("bin/npm");
118
119 let output = Command::new(node_binary)
120 .arg(npm_file)
121 .args(["-fetch-retry-mintimeout", "2000"])
122 .args(["-fetch-retry-maxtimeout", "5000"])
123 .args(["-fetch-timeout", "5000"])
124 .arg("install")
125 .arg("--prefix")
126 .arg(directory)
127 .args(
128 packages
129 .into_iter()
130 .map(|(name, version)| format!("{name}@{version}")),
131 )
132 .output()
133 .await
134 .context("failed to run npm install")?;
135
136 if !output.status.success() {
137 return Err(anyhow!(
138 "failed to execute npm install:\nstdout: {:?}\nstderr: {:?}",
139 String::from_utf8_lossy(&output.stdout),
140 String::from_utf8_lossy(&output.stderr)
141 ));
142 }
143 Ok(())
144 }
145
146 async fn install_if_needed(&self) -> Result<PathBuf> {
147 let task = self
148 .installation_path
149 .lock()
150 .get_or_insert_with(|| {
151 let http = self.http.clone();
152 self.background
153 .spawn(async move { Self::install(http).await.map_err(Arc::new) })
154 .shared()
155 })
156 .clone();
157
158 match task.await {
159 Ok(path) => Ok(path),
160 Err(error) => Err(anyhow!("{}", error)),
161 }
162 }
163
164 async fn install(http: Arc<dyn HttpClient>) -> Result<PathBuf> {
165 let arch = match consts::ARCH {
166 "x86_64" => "x64",
167 "aarch64" => "arm64",
168 other => bail!("Running on unsupported platform: {other}"),
169 };
170
171 let folder_name = format!("node-{VERSION}-darwin-{arch}");
172 let node_containing_dir = util::paths::SUPPORT_DIR.join("node");
173 let node_dir = node_containing_dir.join(folder_name);
174 let node_binary = node_dir.join("bin/node");
175
176 if fs::metadata(&node_binary).await.is_err() {
177 _ = fs::remove_dir_all(&node_containing_dir).await;
178 fs::create_dir(&node_containing_dir)
179 .await
180 .context("error creating node containing dir")?;
181
182 let file_name = format!("node-{VERSION}-darwin-{arch}.tar.gz");
183 let url = format!("https://nodejs.org/dist/{VERSION}/{file_name}");
184 let mut response = http
185 .get(&url, Default::default(), true)
186 .await
187 .context("error downloading Node binary tarball")?;
188
189 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
190 let archive = Archive::new(decompressed_bytes);
191 archive.unpack(&node_containing_dir).await?;
192 }
193
194 anyhow::Ok(node_dir)
195 }
196}