node_runtime.rs

  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}