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