Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "node_runtime"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
local_fs = []
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
cfg-if = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
log = { workspace = true }
|
||||
http_client = { workspace = true }
|
||||
semver = "1.0"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
warp_core = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
async-fs = { workspace = true }
|
||||
command.workspace = true
|
||||
flate2 = "1.0"
|
||||
sha2 = { workspace = true }
|
||||
tar = "0.4"
|
||||
zip = "2.1"
|
||||
|
||||
[build-dependencies]
|
||||
anyhow.workspace = true
|
||||
@@ -0,0 +1,11 @@
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let target_family = std::env::var("CARGO_CFG_TARGET_FAMILY")?;
|
||||
|
||||
if target_family != "wasm" {
|
||||
println!("cargo:rustc-cfg=feature=\"local_fs\"");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
//! Node.js and npm runtime management for Warp.
|
||||
//!
|
||||
//! This module provides functionality to install and manage Node.js/npm,
|
||||
//! supporting multiple platforms (macOS, Linux, Windows) and architectures
|
||||
//! (x64, arm64).
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
use flate2::read::GzDecoder;
|
||||
use futures::io::AsyncWriteExt;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tar::Archive;
|
||||
use command::r#async::Command;
|
||||
use semver::Version;
|
||||
}
|
||||
}
|
||||
|
||||
/// The pinned Node.js version to install.
|
||||
#[cfg(feature = "local_fs")]
|
||||
const NODE_VERSION: &str = "v22.12.0";
|
||||
|
||||
/// Minimum supported Node.js version for system-installed Node.
|
||||
#[cfg(feature = "local_fs")]
|
||||
const MIN_NODE_VERSION: Version = Version::new(20, 0, 0);
|
||||
|
||||
// Platform-specific paths for Node.js binaries
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(all(feature = "local_fs", windows))] {
|
||||
const NODE_BINARY_PATH: &str = "node.exe";
|
||||
const NPM_BINARY_PATH: &str = "node_modules/npm/bin/npm-cli.js";
|
||||
} else if #[cfg(feature = "local_fs")] {
|
||||
const NODE_BINARY_PATH: &str = "bin/node";
|
||||
const NPM_BINARY_PATH: &str = "bin/npm";
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about an npm package from the npm registry.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct NpmInfo {
|
||||
#[serde(default)]
|
||||
dist_tags: NpmInfoDistTags,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct NpmInfoDistTags {
|
||||
latest: Option<String>,
|
||||
}
|
||||
|
||||
impl NpmInfo {
|
||||
/// Returns the latest version of the package.
|
||||
pub fn latest_version(&self) -> Option<&str> {
|
||||
self.dist_tags.latest.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive type for Node.js distribution.
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ArchiveType {
|
||||
/// A gzip-compressed tarball (for macOS and Linux)
|
||||
TarGz,
|
||||
/// A zip archive (for Windows)
|
||||
Zip,
|
||||
}
|
||||
|
||||
/// Platform-specific Node.js distribution information.
|
||||
#[cfg(feature = "local_fs")]
|
||||
struct NodeDistribution {
|
||||
os: &'static str,
|
||||
arch: &'static str,
|
||||
archive_type: ArchiveType,
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl NodeDistribution {
|
||||
/// Determines the Node.js distribution for the current platform.
|
||||
fn current() -> Result<Self> {
|
||||
let os = match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
"linux" => "linux",
|
||||
"windows" => "win",
|
||||
other => bail!("Unsupported operating system: {}", other),
|
||||
};
|
||||
|
||||
let arch = match std::env::consts::ARCH {
|
||||
"x86_64" => "x64",
|
||||
"aarch64" => "arm64",
|
||||
other => bail!("Unsupported architecture: {}", other),
|
||||
};
|
||||
|
||||
let archive_type = match std::env::consts::OS {
|
||||
"windows" => ArchiveType::Zip,
|
||||
_ => ArchiveType::TarGz,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
os,
|
||||
arch,
|
||||
archive_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the folder name for the extracted Node.js distribution.
|
||||
fn folder_name(&self, version: &str) -> String {
|
||||
format!("node-{}-{}-{}", version, self.os, self.arch)
|
||||
}
|
||||
|
||||
/// Returns the file extension for the archive.
|
||||
fn file_extension(&self) -> &'static str {
|
||||
match self.archive_type {
|
||||
ArchiveType::TarGz => "tar.gz",
|
||||
ArchiveType::Zip => "zip",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the download URL for the Node.js distribution.
|
||||
fn download_url(&self, version: &str) -> String {
|
||||
let file_name = format!(
|
||||
"node-{}-{}-{}.{}",
|
||||
version,
|
||||
self.os,
|
||||
self.arch,
|
||||
self.file_extension()
|
||||
);
|
||||
format!("https://nodejs.org/dist/{}/{}", version, file_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path to the Node.js installation directory.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn node_installation_dir() -> Result<PathBuf> {
|
||||
let dist = NodeDistribution::current()?;
|
||||
let folder_name = dist.folder_name(NODE_VERSION);
|
||||
Ok(warp_core::paths::data_dir().join("node").join(folder_name))
|
||||
}
|
||||
|
||||
/// Returns the path to the installed node binary.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn node_binary_path() -> Result<PathBuf> {
|
||||
Ok(node_installation_dir()?.join(NODE_BINARY_PATH))
|
||||
}
|
||||
|
||||
/// Returns the path to the installed npm binary/script.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn npm_binary_path() -> Result<PathBuf> {
|
||||
Ok(node_installation_dir()?.join(NPM_BINARY_PATH))
|
||||
}
|
||||
|
||||
/// Installs Node.js and npm if not already installed.
|
||||
///
|
||||
/// This function will:
|
||||
/// 1. Check if a valid Node.js installation already exists
|
||||
/// 2. Download the appropriate Node.js distribution for the current platform
|
||||
/// 3. Extract it to the Warp data directory
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the path to the Node.js installation directory on success.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - The current platform/architecture is not supported
|
||||
/// - Network errors occur during download
|
||||
/// - Extraction fails
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn install_npm(client: &http_client::Client) -> Result<PathBuf> {
|
||||
log::info!("Node.js runtime install_npm called");
|
||||
|
||||
let dist = NodeDistribution::current()?;
|
||||
let version = NODE_VERSION;
|
||||
|
||||
let folder_name = dist.folder_name(version);
|
||||
let node_containing_dir = warp_core::paths::data_dir().join("node");
|
||||
let node_dir = node_containing_dir.join(&folder_name);
|
||||
let node_binary = node_dir.join(NODE_BINARY_PATH);
|
||||
|
||||
// Check if we already have a valid installation
|
||||
if is_valid_installation(&node_binary).await {
|
||||
log::info!(
|
||||
"Using existing Node.js installation at {}",
|
||||
node_dir.display()
|
||||
);
|
||||
return Ok(node_dir);
|
||||
}
|
||||
|
||||
// Remove any existing (potentially corrupted) installation
|
||||
if async_fs::metadata(&node_containing_dir).await.is_ok() {
|
||||
log::info!("Removing existing Node.js directory for clean installation");
|
||||
async_fs::remove_dir_all(&node_containing_dir)
|
||||
.await
|
||||
.context("Failed to remove existing Node.js directory")?;
|
||||
}
|
||||
|
||||
// Create the containing directory
|
||||
async_fs::create_dir_all(&node_containing_dir)
|
||||
.await
|
||||
.context("Failed to create Node.js containing directory")?;
|
||||
|
||||
// Download Node.js
|
||||
let url = dist.download_url(version);
|
||||
log::info!("Downloading Node.js from {}", url);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to download Node.js")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"Node.js download failed with status {}: {}",
|
||||
response.status(),
|
||||
response.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read Node.js download response")?;
|
||||
|
||||
log::info!("Download complete, extracting...");
|
||||
|
||||
// Extract the archive
|
||||
match dist.archive_type {
|
||||
ArchiveType::TarGz => {
|
||||
extract_tar_gz(&bytes, &node_containing_dir)?;
|
||||
}
|
||||
ArchiveType::Zip => {
|
||||
extract_zip(&bytes, &node_containing_dir, None::<fn(&str) -> bool>).await?;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Node.js extracted successfully to {}", node_dir.display());
|
||||
|
||||
Ok(node_dir)
|
||||
}
|
||||
|
||||
/// Checks if an existing Node.js installation is valid.
|
||||
#[cfg(feature = "local_fs")]
|
||||
async fn is_valid_installation(node_binary: &Path) -> bool {
|
||||
// Check if the binary exists
|
||||
if async_fs::metadata(node_binary).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to run node --version to verify it works
|
||||
let result = Command::new(node_binary).arg("--version").output().await;
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
log::debug!(
|
||||
"Node.js version check passed: {}",
|
||||
String::from_utf8_lossy(&output.stdout).trim()
|
||||
);
|
||||
true
|
||||
} else {
|
||||
log::warn!(
|
||||
"Node.js version check failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to run Node.js binary: {}", err);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a gzip-compressed tarball to the destination directory.
|
||||
///
|
||||
/// This is a simple wrapper around tar::Archive that extracts all contents.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn extract_tar_gz(data: &[u8], dest_dir: &Path) -> Result<()> {
|
||||
let decoder = GzDecoder::new(data);
|
||||
let mut archive = Archive::new(decoder);
|
||||
archive
|
||||
.unpack(dest_dir)
|
||||
.context("Failed to extract tar.gz archive")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extracts a single gzip-compressed file to the destination path.
|
||||
///
|
||||
/// This decompresses a `.gz` file (not a `.tar.gz` archive) directly to a file.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn extract_gz(data: &[u8], dest_path: &Path) -> Result<()> {
|
||||
let mut decoder = GzDecoder::new(data);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut decompressed)
|
||||
.context("Failed to decompress gzip data")?;
|
||||
|
||||
let mut file = async_fs::File::create(dest_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create file: {:?}", dest_path))?;
|
||||
file.write_all(&decompressed)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write to {:?}", dest_path))?;
|
||||
file.flush()
|
||||
.await
|
||||
.with_context(|| format!("Failed to flush {:?}", dest_path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extracts files from a zip archive to the destination directory.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - The zip archive data
|
||||
/// * `dest_dir` - The destination directory to extract to
|
||||
/// * `file_filter` - Optional filter function that takes a file name and returns true if it should be extracted.
|
||||
/// If None, all files are extracted.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns Ok(()) on success, or an error if extraction fails or if a filter is provided but no files match.
|
||||
///
|
||||
/// Note: This function performs synchronous zip reading (which is CPU-bound) followed by
|
||||
/// async file I/O. Similar to `extract_gz`, this approach is acceptable for reasonably-sized
|
||||
/// archives since the zip reading is relatively fast.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn extract_zip<F>(data: &[u8], dest_dir: &Path, file_filter: Option<F>) -> Result<()>
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
// Extract all file data synchronously first
|
||||
let files_to_write: Vec<(PathBuf, Vec<u8>)> = {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).context("Failed to read zip archive")?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
|
||||
let file_name = file.name().to_string();
|
||||
|
||||
if let Some(ref filter) = file_filter {
|
||||
if !filter(&file_name) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let outpath = match file.enclosed_name() {
|
||||
Some(path) => dest_dir.join(path),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !file.is_dir() {
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data)
|
||||
.context("Failed to read file from zip")?;
|
||||
result.push((outpath, data));
|
||||
}
|
||||
}
|
||||
result
|
||||
}; // archive is dropped here
|
||||
|
||||
// If a filter was provided but no files matched, return an error
|
||||
if file_filter.is_some() && files_to_write.is_empty() {
|
||||
bail!("No files matched the filter in the zip archive");
|
||||
}
|
||||
|
||||
// Now do async file I/O
|
||||
for (outpath, data) in files_to_write {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
async_fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
|
||||
let mut outfile = async_fs::File::create(&outpath)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create file: {:?}", outpath))?;
|
||||
outfile
|
||||
.write_all(&data)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file: {:?}", outpath))?;
|
||||
outfile
|
||||
.flush()
|
||||
.await
|
||||
.with_context(|| format!("Failed to flush file: {:?}", outpath))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finds a working Node.js binary, preferring our custom installation over system node.
|
||||
///
|
||||
/// This function checks:
|
||||
/// 1. First, our custom Node.js installation in the Warp data directory
|
||||
/// 2. Falls back to system Node.js if custom isn't available
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path_env_var` - The PATH environment variable to use when checking for system node.
|
||||
/// If None, system node will not be checked.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the path to a working node binary, or `None` if no working node is found.
|
||||
/// For system node, returns `PathBuf::from("node")` to let PATH resolution handle it.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn find_working_node_binary(path_env_var: Option<&str>) -> Option<PathBuf> {
|
||||
// First, try our custom node installation
|
||||
if let Ok(custom_node) = node_binary_path() {
|
||||
if custom_node.is_file() {
|
||||
let mut cmd = Command::new(&custom_node);
|
||||
cmd.arg("--version");
|
||||
if let Ok(output) = cmd.output().await {
|
||||
if output.status.success() {
|
||||
log::info!(
|
||||
"Using custom node installation at {}",
|
||||
custom_node.display()
|
||||
);
|
||||
return Some(custom_node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to system node if available
|
||||
if let Some(path_env_var) = path_env_var {
|
||||
if detect_system_node(path_env_var).await.is_ok() {
|
||||
// System node is available and meets version requirements.
|
||||
// Use "node" and let the PATH resolve it.
|
||||
log::info!("Using system node");
|
||||
return Some(PathBuf::from("node"));
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("No working node binary found");
|
||||
None
|
||||
}
|
||||
|
||||
/// Detects and validates the system-installed Node.js meets the minimum version requirement.
|
||||
///
|
||||
/// This function runs `node --version` using the provided PATH environment variable
|
||||
/// and verifies that the installed version meets the minimum requirement.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path_env_var` - The PATH environment variable value to use when running node
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if Node.js is not found or doesn't meet the minimum version.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn detect_system_node(path_env_var: impl AsRef<OsStr>) -> Result<()> {
|
||||
let path_env_var = path_env_var.as_ref();
|
||||
|
||||
// On Windows, we must use `cmd.exe /c node` so that the provided PATH
|
||||
// (set via `.env("PATH", ...)`) is used for executable search.
|
||||
// `CreateProcessW` uses the parent process's PATH, not the child's
|
||||
// `lpEnvironment` PATH, so running `node` directly would find node.exe
|
||||
// via Warp's inherited env rather than the captured interactive PATH.
|
||||
#[cfg(windows)]
|
||||
let output = Command::new("cmd.exe")
|
||||
.args(["/c", "node", "--version"])
|
||||
.env("PATH", path_env_var)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run node --version. Is Node.js installed?")?;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let output = Command::new("node")
|
||||
.env("PATH", path_env_var)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run node --version. Is Node.js installed?")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"node --version failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let version_str = String::from_utf8_lossy(&output.stdout);
|
||||
let version_str = version_str.trim().trim_start_matches('v');
|
||||
let version = Version::parse(version_str)
|
||||
.with_context(|| format!("Failed to parse Node.js version: {}", version_str))?;
|
||||
|
||||
if version < MIN_NODE_VERSION {
|
||||
bail!(
|
||||
"System Node.js version {} is too old. Minimum required: {}",
|
||||
version,
|
||||
MIN_NODE_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Detected system Node.js {}", version);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches the latest version of an npm package.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `client` - The HTTP client to use for the request
|
||||
/// * `package_name` - The name of the npm package
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the latest version string on success.
|
||||
pub async fn fetch_npm_package_version(
|
||||
client: &http_client::Client,
|
||||
package_name: &str,
|
||||
) -> Result<String> {
|
||||
let url = format!("https://registry.npmjs.org/{}", package_name);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to fetch npm info for {}", package_name))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"npm registry returned status {} for {}",
|
||||
response.status(),
|
||||
package_name
|
||||
);
|
||||
}
|
||||
|
||||
let info: NpmInfo = response
|
||||
.json()
|
||||
.await
|
||||
.with_context(|| format!("Failed to parse npm info for {}", package_name))?;
|
||||
|
||||
info.latest_version()
|
||||
.map(|s| s.to_string())
|
||||
.with_context(|| format!("No version found for npm package {}", package_name))
|
||||
}
|
||||
Reference in New Issue
Block a user