Add `zip` extract support for Windows (#11156)

Jason Lee and Marshall Bowers created

Release Notes:

- [x] Fixed install Node.js runtime and NPM lsp installation on Windows.
- [x] Update Node runtime command to execute on Windows with no window
popup.

Ref #9619, #9424

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

Cargo.lock                              |  29 ++++++
Cargo.toml                              |   3 
crates/node_runtime/Cargo.toml          |  13 ++
crates/node_runtime/src/archive.rs      | 118 +++++++++++++++++++++++++++
crates/node_runtime/src/node_runtime.rs |  85 +++++++++++++++---
5 files changed, 228 insertions(+), 20 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -483,6 +483,7 @@ version = "0.4.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a116f46a969224200a0a97f29cfd4c50e7534e4b4826bd23ea2c3c533039c82c"
 dependencies = [
+ "deflate64",
  "flate2",
  "futures-core",
  "futures-io",
@@ -816,6 +817,19 @@ dependencies = [
  "tungstenite 0.16.0",
 ]
 
+[[package]]
+name = "async_zip"
+version = "0.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52"
+dependencies = [
+ "async-compression",
+ "crc32fast",
+ "futures-lite 2.2.0",
+ "pin-project",
+ "thiserror",
+]
+
 [[package]]
 name = "atoi"
 version = "2.0.0"
@@ -3111,6 +3125,12 @@ dependencies = [
  "byteorder",
 ]
 
+[[package]]
+name = "deflate64"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83ace6c86376be0b6cdcf3fb41882e81d94b31587573d1cfa9d01cd06bba210d"
+
 [[package]]
 name = "der"
 version = "0.6.1"
@@ -6361,15 +6381,20 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "async-compression",
+ "async-std",
  "async-tar",
  "async-trait",
+ "async_zip",
  "futures 0.3.28",
  "log",
  "semver",
  "serde",
  "serde_json",
  "smol",
+ "tempfile",
  "util",
+ "walkdir",
+ "windows 0.53.0",
 ]
 
 [[package]]
@@ -11317,9 +11342,9 @@ checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca"
 
 [[package]]
 name = "walkdir"
-version = "2.4.0"
+version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
 dependencies = [
  "same-file",
  "winapi-util",

Cargo.toml 🔗

@@ -4,8 +4,8 @@ members = [
     "crates/anthropic",
     "crates/assets",
     "crates/assistant",
-    "crates/assistant_tooling",
     "crates/assistant2",
+    "crates/assistant_tooling",
     "crates/audio",
     "crates/auto_update",
     "crates/breadcrumbs",
@@ -257,6 +257,7 @@ async-fs = "1.6"
 async-recursion = "1.0.0"
 async-tar = "0.4.2"
 async-trait = "0.1"
+async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] }
 bitflags = "2.4.2"
 blade-graphics = { git = "https://github.com/kvark/blade", rev = "e35b2d41f221a48b75f7cf2e78a81e7ecb7a383c" }
 blade-macros = { git = "https://github.com/kvark/blade", rev = "e35b2d41f221a48b75f7cf2e78a81e7ecb7a383c" }

crates/node_runtime/Cargo.toml 🔗

@@ -12,15 +12,28 @@ workspace = true
 path = "src/node_runtime.rs"
 doctest = false
 
+[features]
+test-support = ["tempfile"]
+
 [dependencies]
 anyhow.workspace = true
 async-compression.workspace = true
 async-tar.workspace = true
 async-trait.workspace = true
+async_zip.workspace = true
 futures.workspace = true
 log.workspace = true
 semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 smol.workspace = true
+tempfile = { workspace = true, optional = true }
 util.workspace = true
+walkdir = "2.5.0"
+windows.workspace = true
+
+[target.'cfg(windows)'.dependencies]
+async-std = { version = "1.12.0", features = ["unstable"] }
+
+[dev-dependencies]
+tempfile.workspace = true

crates/node_runtime/src/archive.rs 🔗

@@ -0,0 +1,118 @@
+use std::path::Path;
+
+use anyhow::Result;
+use async_zip::base::read::stream::ZipFileReader;
+use futures::{io::BufReader, AsyncRead};
+
+pub async fn extract_zip<R: AsyncRead + Unpin>(destination: &Path, reader: R) -> Result<()> {
+    let mut reader = ZipFileReader::new(BufReader::new(reader));
+
+    let destination = &destination
+        .canonicalize()
+        .unwrap_or_else(|_| destination.to_path_buf());
+
+    while let Some(mut item) = reader.next_with_entry().await? {
+        let entry_reader = item.reader_mut();
+        let entry = entry_reader.entry();
+        let path = destination.join(entry.filename().as_str().unwrap());
+
+        if entry.dir().unwrap() {
+            std::fs::create_dir_all(&path)?;
+        } else {
+            let parent_dir = path.parent().expect("failed to get parent directory");
+            std::fs::create_dir_all(&parent_dir)?;
+            let mut file = smol::fs::File::create(&path).await?;
+            futures::io::copy(entry_reader, &mut file).await?;
+        }
+
+        reader = item.skip().await?;
+    }
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use std::path::PathBuf;
+
+    use async_zip::base::write::ZipFileWriter;
+    use async_zip::ZipEntryBuilder;
+    use futures::AsyncWriteExt;
+    use smol::io::Cursor;
+    use tempfile::TempDir;
+
+    use super::*;
+
+    async fn compress_zip(src_dir: &Path, dst: &Path) -> Result<()> {
+        let mut out = smol::fs::File::create(dst).await?;
+        let mut writer = ZipFileWriter::new(&mut out);
+
+        for entry in walkdir::WalkDir::new(src_dir) {
+            let entry = entry?;
+            let path = entry.path();
+
+            if path.is_dir() {
+                continue;
+            }
+
+            let relative_path = path.strip_prefix(src_dir)?;
+            let data = smol::fs::read(&path).await?;
+
+            let filename = relative_path.display().to_string();
+            let builder = ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
+
+            writer.write_entry_whole(builder, &data).await?;
+        }
+
+        writer.close().await?;
+        out.flush().await?;
+
+        Ok(())
+    }
+
+    #[track_caller]
+    fn assert_file_content(path: &Path, content: &str) {
+        assert!(path.exists(), "file not found: {:?}", path);
+        let actual = std::fs::read_to_string(path).unwrap();
+        assert_eq!(actual, content);
+    }
+
+    #[track_caller]
+    fn make_test_data() -> TempDir {
+        let dir = tempfile::tempdir().unwrap();
+        let dst = dir.path();
+
+        std::fs::write(&dst.join("test"), "Hello world.").unwrap();
+        std::fs::create_dir_all(&dst.join("foo/bar")).unwrap();
+        std::fs::write(&dst.join("foo/bar.txt"), "Foo bar.").unwrap();
+        std::fs::write(&dst.join("foo/dar.md"), "Bar dar.").unwrap();
+        std::fs::write(&dst.join("foo/bar/dar你好.txt"), "你好世界").unwrap();
+
+        dir
+    }
+
+    async fn read_archive(path: &PathBuf) -> impl AsyncRead + Unpin {
+        let data = smol::fs::read(&path).await.unwrap();
+        Cursor::new(data)
+    }
+
+    #[test]
+    fn test_extract_zip() {
+        let test_dir = make_test_data();
+        let zip_file = test_dir.path().join("test.zip");
+
+        smol::block_on(async {
+            compress_zip(&test_dir.path(), &zip_file).await.unwrap();
+            let reader = read_archive(&zip_file).await;
+
+            let dir = tempfile::tempdir().unwrap();
+            let dst = dir.path();
+            extract_zip(dst, reader).await.unwrap();
+
+            assert_file_content(&dst.join("test"), "Hello world.");
+            assert_file_content(&dst.join("foo/bar.txt"), "Foo bar.");
+            assert_file_content(&dst.join("foo/dar.md"), "Bar dar.");
+            assert_file_content(&dst.join("foo/bar/dar你好.txt"), "你好世界");
+        });
+    }
+}

crates/node_runtime/src/node_runtime.rs 🔗

@@ -1,10 +1,13 @@
+mod archive;
+
 use anyhow::{anyhow, bail, Context, Result};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use futures::AsyncReadExt;
 use semver::Version;
 use serde::Deserialize;
-use smol::{fs, io::BufReader, lock::Mutex, process::Command};
+use smol::io::BufReader;
+use smol::{fs, lock::Mutex, process::Command};
 use std::io;
 use std::process::{Output, Stdio};
 use std::{
@@ -15,8 +18,26 @@ use std::{
 use util::http::HttpClient;
 use util::ResultExt;
 
+#[cfg(windows)]
+use smol::process::windows::CommandExt;
+
 const VERSION: &str = "v18.15.0";
 
+#[cfg(not(windows))]
+const NODE_PATH: &str = "bin/node";
+#[cfg(windows)]
+const NODE_PATH: &str = "node.exe";
+
+#[cfg(not(windows))]
+const NPM_PATH: &str = "bin/npm";
+#[cfg(windows)]
+const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";
+
+enum ArchiveType {
+    TarGz,
+    Zip,
+}
+
 #[derive(Debug, Deserialize)]
 #[serde(rename_all = "kebab-case")]
 pub struct NpmInfo {
@@ -119,10 +140,12 @@ impl RealNodeRuntime {
         let folder_name = format!("node-{VERSION}-{os}-{arch}");
         let node_containing_dir = util::paths::SUPPORT_DIR.join("node");
         let node_dir = node_containing_dir.join(folder_name);
-        let node_binary = node_dir.join("bin/node");
-        let npm_file = node_dir.join("bin/npm");
+        let node_binary = node_dir.join(NODE_PATH);
+        let npm_file = node_dir.join(NPM_PATH);
 
-        let result = Command::new(&node_binary)
+        let mut command = Command::new(&node_binary);
+
+        command
             .env_clear()
             .arg(npm_file)
             .arg("--version")
@@ -131,9 +154,12 @@ impl RealNodeRuntime {
             .stderr(Stdio::null())
             .args(["--cache".into(), node_dir.join("cache")])
             .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
-            .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
-            .status()
-            .await;
+            .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")]);
+
+        #[cfg(windows)]
+        command.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0);
+
+        let result = command.status().await;
         let valid = matches!(result, Ok(status) if status.success());
 
         if !valid {
@@ -142,7 +168,19 @@ impl RealNodeRuntime {
                 .await
                 .context("error creating node containing dir")?;
 
-            let file_name = format!("node-{VERSION}-{os}-{arch}.tar.gz");
+            let archive_type = match consts::OS {
+                "macos" | "linux" => ArchiveType::TarGz,
+                "windows" => ArchiveType::Zip,
+                other => bail!("Running on unsupported os: {other}"),
+            };
+
+            let file_name = format!(
+                "node-{VERSION}-{os}-{arch}.{extension}",
+                extension = match archive_type {
+                    ArchiveType::TarGz => "tar.gz",
+                    ArchiveType::Zip => "zip",
+                }
+            );
             let url = format!("https://nodejs.org/dist/{VERSION}/{file_name}");
             let mut response = self
                 .http
@@ -150,9 +188,15 @@ impl RealNodeRuntime {
                 .await
                 .context("error downloading Node binary tarball")?;
 
-            let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
-            let archive = Archive::new(decompressed_bytes);
-            archive.unpack(&node_containing_dir).await?;
+            let body = response.body_mut();
+            match archive_type {
+                ArchiveType::TarGz => {
+                    let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
+                    let archive = Archive::new(decompressed_bytes);
+                    archive.unpack(&node_containing_dir).await?;
+                }
+                ArchiveType::Zip => archive::extract_zip(&node_containing_dir, body).await?,
+            }
         }
 
         // Note: Not in the `if !valid {}` so we can populate these for existing installations
@@ -168,7 +212,7 @@ impl RealNodeRuntime {
 impl NodeRuntime for RealNodeRuntime {
     async fn binary_path(&self) -> Result<PathBuf> {
         let installation_path = self.install_if_needed().await?;
-        Ok(installation_path.join("bin/node"))
+        Ok(installation_path.join(NODE_PATH))
     }
 
     async fn run_npm_subcommand(
@@ -180,7 +224,13 @@ impl NodeRuntime for RealNodeRuntime {
         let attempt = || async move {
             let installation_path = self.install_if_needed().await?;
 
-            let mut env_path = installation_path.join("bin").into_os_string();
+            let node_binary = installation_path.join(NODE_PATH);
+            let npm_file = installation_path.join(NPM_PATH);
+            let mut env_path = node_binary
+                .parent()
+                .expect("invalid node binary path")
+                .to_path_buf();
+
             if let Some(existing_path) = std::env::var_os("PATH") {
                 if !existing_path.is_empty() {
                     env_path.push(":");
@@ -188,9 +238,6 @@ impl NodeRuntime for RealNodeRuntime {
                 }
             }
 
-            let node_binary = installation_path.join("bin/node");
-            let npm_file = installation_path.join("bin/npm");
-
             if smol::fs::metadata(&node_binary).await.is_err() {
                 return Err(anyhow!("missing node binary file"));
             }
@@ -219,6 +266,9 @@ impl NodeRuntime for RealNodeRuntime {
                 command.args(["--prefix".into(), directory.to_path_buf()]);
             }
 
+            #[cfg(windows)]
+            command.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0);
+
             command.output().await.map_err(|e| anyhow!("{e}"))
         };
 
@@ -227,7 +277,8 @@ impl NodeRuntime for RealNodeRuntime {
             output = attempt().await;
             if output.is_err() {
                 return Err(anyhow!(
-                    "failed to launch npm subcommand {subcommand} subcommand"
+                    "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}",
+                    output.err()
                 ));
             }
         }