diff --git a/Cargo.lock b/Cargo.lock index 9efbb87c7a..bede039bf7 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index 6768e1132b..ceab28c4fb 100644 --- a/Cargo.toml +++ b/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" } diff --git a/crates/node_runtime/Cargo.toml b/crates/node_runtime/Cargo.toml index 1097f85f38..f6b7bb65a7 100644 --- a/crates/node_runtime/Cargo.toml +++ b/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 diff --git a/crates/node_runtime/src/archive.rs b/crates/node_runtime/src/archive.rs new file mode 100644 index 0000000000..c17450f92d --- /dev/null +++ b/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(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"), "你好世界"); + }); + } +} diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 10aab50b70..9b2d55ce37 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/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 { 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() )); } }