mirror of
https://github.com/zhom/banderole.git
synced 2026-06-12 17:17:51 +02:00
init
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
use crate::node_downloader::NodeDownloader;
|
||||
use crate::platform::Platform;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
use base64::Engine as _;
|
||||
use zip::ZipWriter;
|
||||
|
||||
/// Public entry-point used by `main.rs`.
|
||||
///
|
||||
/// * `project_path` – path that contains a `package.json`.
|
||||
/// * `output_path` – optional path to the produced bundle file. If omitted, an
|
||||
/// automatically-generated name is used.
|
||||
///
|
||||
/// The implementation uses a simpler, more reliable approach based on Playwright's bundling strategy.
|
||||
pub async fn bundle_project(project_path: PathBuf, output_path: Option<PathBuf>) -> Result<()> {
|
||||
// 1. Validate & canonicalize input directory.
|
||||
let project_path = project_path
|
||||
.canonicalize()
|
||||
.context("Failed to resolve project path")?;
|
||||
let pkg_json = project_path.join("package.json");
|
||||
anyhow::ensure!(pkg_json.exists(), "package.json not found in {}", project_path.display());
|
||||
|
||||
// 2. Read `package.json` for name/version (best-effort – fall back if absent).
|
||||
let (app_name, app_version) = {
|
||||
let content = fs::read_to_string(&pkg_json).context("Failed to read package.json")?;
|
||||
let value: Value = serde_json::from_str(&content).context("Failed to parse package.json")?;
|
||||
(
|
||||
value["name"].as_str().unwrap_or("app").to_string(),
|
||||
value["version"].as_str().unwrap_or("0.0.0").to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
// 3. Determine Node version (via .nvmrc / .node-version or default to LTS 22.17.1).
|
||||
let node_version = detect_node_version(&project_path).unwrap_or_else(|_| "22.17.1".into());
|
||||
|
||||
println!(
|
||||
"Bundling {app_name} v{app_version} using Node.js v{node_version} for {plat}",
|
||||
plat = Platform::current()
|
||||
);
|
||||
|
||||
// 4. Resolve output path.
|
||||
let output_path = output_path.unwrap_or_else(|| {
|
||||
let ext = if Platform::current().is_windows() { ".exe" } else { "" };
|
||||
PathBuf::from(format!(
|
||||
"{name}-{ver}-{plat}{ext}",
|
||||
name = &app_name,
|
||||
ver = &app_version,
|
||||
plat = Platform::current(),
|
||||
ext = ext,
|
||||
))
|
||||
});
|
||||
|
||||
// 5. Ensure portable Node binary is available.
|
||||
let node_downloader = NodeDownloader::new_with_persistent_cache(node_version.clone())?;
|
||||
let node_executable = node_downloader.ensure_node_binary().await?;
|
||||
let node_root = node_executable
|
||||
.parent()
|
||||
.expect("node executable must have a parent")
|
||||
.parent()
|
||||
.unwrap_or_else(|| panic!("Unexpected node layout for {}", node_executable.display()));
|
||||
|
||||
// 6. Create an in-memory zip archive containing `/app` and `/node` directories.
|
||||
let mut zip_data: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut zip_data));
|
||||
let opts: zip::write::FileOptions<'static, ()> = zip::write::FileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
// Copy project directory.
|
||||
add_dir_to_zip(&mut zip, &project_path, Path::new("app"), opts)?;
|
||||
// Copy Node runtime directory.
|
||||
add_dir_to_zip(&mut zip, node_root, Path::new("node"), opts)?;
|
||||
zip.finish()?;
|
||||
}
|
||||
|
||||
// 7. Build self-extracting launcher using a more reliable approach.
|
||||
create_self_extracting_executable(&output_path, zip_data, &app_name)?;
|
||||
|
||||
println!("Bundle created at {}", output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Very lightweight Node version detection.
|
||||
fn detect_node_version(project_path: &Path) -> Result<String> {
|
||||
for file in [".nvmrc", ".node-version"] {
|
||||
let path = project_path.join(file);
|
||||
if path.exists() {
|
||||
let v = fs::read_to_string(&path)?;
|
||||
return Ok(normalise_node_version(v.trim()));
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Node version not found")
|
||||
}
|
||||
|
||||
fn normalise_node_version(raw: &str) -> String {
|
||||
raw.trim_start_matches('v').to_owned()
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Self-extracting executable generation using a more reliable approach
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn create_self_extracting_executable(out: &Path, zip_data: Vec<u8>, app_name: &str) -> Result<()> {
|
||||
let build_id = Uuid::new_v4();
|
||||
|
||||
if Platform::current().is_windows() {
|
||||
create_windows_executable(out, zip_data, app_name, &build_id.to_string())
|
||||
} else {
|
||||
create_unix_executable(out, zip_data, app_name, &build_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn create_unix_executable(out: &Path, zip_data: Vec<u8>, app_name: &str, build_id: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut file = fs::File::create(out).context("Failed to create output executable")?;
|
||||
|
||||
// Write a simpler, more reliable shell script
|
||||
let script = format!(r#"#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Determine cache directory using directories pattern
|
||||
if [ -n "$XDG_CACHE_HOME" ]; then
|
||||
CACHE_DIR="$XDG_CACHE_HOME/banderole"
|
||||
elif [ -n "$HOME" ]; then
|
||||
CACHE_DIR="$HOME/.cache/banderole"
|
||||
else
|
||||
CACHE_DIR="/tmp/banderole-cache"
|
||||
fi
|
||||
|
||||
APP_DIR="$CACHE_DIR/{}"
|
||||
|
||||
# Check if already extracted and ready
|
||||
if [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then
|
||||
# Already extracted, run directly
|
||||
cd "$APP_DIR/app"
|
||||
|
||||
# Find main script from package.json
|
||||
MAIN_SCRIPT=$("$APP_DIR/node/bin/node" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2>/dev/null || echo "index.js")
|
||||
|
||||
if [ -f "$MAIN_SCRIPT" ]; then
|
||||
exec "$APP_DIR/node/bin/node" "$MAIN_SCRIPT" "$@"
|
||||
else
|
||||
echo "Error: Main script '$MAIN_SCRIPT' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract application
|
||||
echo "Extracting {} to cache..." >&2
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
# Create a temporary file for the zip data
|
||||
TEMP_ZIP=$(mktemp)
|
||||
trap "rm -f '$TEMP_ZIP'" EXIT
|
||||
|
||||
# Extract embedded zip data (everything after the __DATA__ marker)
|
||||
awk '/^__DATA__$/{{p=1;next}} p{{print}}' "$0" | base64 -d > "$TEMP_ZIP"
|
||||
|
||||
# Verify we got valid zip data
|
||||
if [ ! -s "$TEMP_ZIP" ]; then
|
||||
echo "Error: Failed to extract bundle data" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the bundle
|
||||
if ! unzip -q "$TEMP_ZIP" -d "$APP_DIR" 2>/dev/null; then
|
||||
echo "Error: Failed to extract bundle" >&2
|
||||
rm -rf "$APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify extraction worked
|
||||
if [ ! -f "$APP_DIR/app/package.json" ] || [ ! -x "$APP_DIR/node/bin/node" ]; then
|
||||
echo "Error: Bundle extraction incomplete" >&2
|
||||
rm -rf "$APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the application
|
||||
cd "$APP_DIR/app"
|
||||
MAIN_SCRIPT=$("$APP_DIR/node/bin/node" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2>/dev/null || echo "index.js")
|
||||
|
||||
if [ -f "$MAIN_SCRIPT" ]; then
|
||||
exec "$APP_DIR/node/bin/node" "$MAIN_SCRIPT" "$@"
|
||||
else
|
||||
echo "Error: Main script '$MAIN_SCRIPT' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
__DATA__
|
||||
"#, build_id, app_name);
|
||||
|
||||
file.write_all(script.as_bytes())?;
|
||||
|
||||
// Append base64-encoded zip data
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
|
||||
file.write_all(encoded.as_bytes())?;
|
||||
file.write_all(b"\n")?;
|
||||
|
||||
// Make executable
|
||||
let mut perms = file.metadata()?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(out, perms)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_windows_executable(out: &Path, zip_data: Vec<u8>, app_name: &str, build_id: &str) -> Result<()> {
|
||||
let mut file = fs::File::create(out).context("Failed to create output executable")?;
|
||||
|
||||
// Create a more reliable Windows batch script
|
||||
let script = format!(r#"@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Determine cache directory
|
||||
set "CACHE_DIR=%LOCALAPPDATA%\banderole"
|
||||
set "APP_DIR=!CACHE_DIR!\{}"
|
||||
|
||||
REM Check if already extracted and ready
|
||||
if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" (
|
||||
cd /d "!APP_DIR!\app"
|
||||
|
||||
REM Find main script
|
||||
for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i"
|
||||
if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js"
|
||||
|
||||
if exist "!MAIN_SCRIPT!" (
|
||||
"!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %*
|
||||
exit /b !errorlevel!
|
||||
) else (
|
||||
echo Error: Main script '!MAIN_SCRIPT!' not found >&2
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
REM Extract application
|
||||
echo Extracting {} to cache... >&2
|
||||
if not exist "!CACHE_DIR!" mkdir "!CACHE_DIR!"
|
||||
if not exist "!APP_DIR!" mkdir "!APP_DIR!"
|
||||
|
||||
REM Create temp file for zip
|
||||
set "TEMP_ZIP=%TEMP%\banderole-bundle-%RANDOM%.zip"
|
||||
|
||||
REM Extract embedded zip data using PowerShell
|
||||
powershell -NoProfile -Command "$content = Get-Content '%~f0' -Raw; $dataStart = $content.IndexOf('__DATA__') + 8; $data = $content.Substring($dataStart).Trim(); [System.IO.File]::WriteAllBytes('%TEMP_ZIP%', [System.Convert]::FromBase64String($data))"
|
||||
|
||||
if not exist "%TEMP_ZIP%" (
|
||||
echo Error: Failed to extract bundle data >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Extract the bundle using PowerShell
|
||||
powershell -NoProfile -Command "try {{ Expand-Archive -Path '%TEMP_ZIP%' -DestinationPath '!APP_DIR!' -Force }} catch {{ Write-Error $_.Exception.Message; exit 1 }}"
|
||||
set "EXTRACT_RESULT=!errorlevel!"
|
||||
del "%TEMP_ZIP%" 2>nul
|
||||
|
||||
if !EXTRACT_RESULT! neq 0 (
|
||||
echo Error: Failed to extract bundle >&2
|
||||
rmdir /s /q "!APP_DIR!" 2>nul
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Verify extraction worked
|
||||
if not exist "!APP_DIR!\app\package.json" (
|
||||
echo Error: Bundle extraction incomplete >&2
|
||||
rmdir /s /q "!APP_DIR!" 2>nul
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "!APP_DIR!\node\node.exe" (
|
||||
echo Error: Node.js executable not found >&2
|
||||
rmdir /s /q "!APP_DIR!" 2>nul
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Run the application
|
||||
cd /d "!APP_DIR!\app"
|
||||
for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i"
|
||||
if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js"
|
||||
|
||||
if exist "!MAIN_SCRIPT!" (
|
||||
"!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %*
|
||||
exit /b !errorlevel!
|
||||
) else (
|
||||
echo Error: Main script '!MAIN_SCRIPT!' not found >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
__DATA__
|
||||
"#, build_id, app_name);
|
||||
|
||||
file.write_all(script.as_bytes())?;
|
||||
|
||||
// Append base64-encoded zip data
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
|
||||
file.write_all(encoded.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Utility helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn add_dir_to_zip<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
src_dir: &Path,
|
||||
dest_dir: &Path,
|
||||
opts: zip::write::FileOptions<'static, ()>,
|
||||
) -> Result<()>
|
||||
where
|
||||
W: Write + Read + std::io::Seek,
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for entry in walkdir::WalkDir::new(src_dir) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let rel_path = path.strip_prefix(src_dir).unwrap();
|
||||
let zip_path = dest_dir.join(rel_path);
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get file permissions to preserve executable bits
|
||||
let metadata = fs::metadata(path)?;
|
||||
let permissions = metadata.permissions();
|
||||
let mode = permissions.mode();
|
||||
|
||||
// Create file options with Unix permissions
|
||||
let file_opts = opts.unix_permissions(mode);
|
||||
|
||||
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
|
||||
let data = fs::read(path).context("Failed to read file while zipping")?;
|
||||
zip.write_all(&data)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
mod bundler_simple;
|
||||
mod node_downloader;
|
||||
mod platform;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "banderole")]
|
||||
#[command(about = "A cross-platform Node.js single-executable bundler")]
|
||||
#[command(version)]
|
||||
#[command(
|
||||
long_about = "Banderole packages Node.js applications with portable Node binaries into a single binary for easy distribution and execution"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Bundle a Node.js project into a self-contained executable
|
||||
Bundle {
|
||||
/// Path to the directory containing package.json
|
||||
path: PathBuf,
|
||||
/// Output path for the bundle (optional)
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Bundle { path, output } => {
|
||||
bundler_simple::bundle_project(path, output).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use crate::platform::Platform;
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
static ref NODE_VERSION_CACHE: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub struct NodeDownloader {
|
||||
platform: Platform,
|
||||
cache_dir: PathBuf,
|
||||
node_version: String,
|
||||
}
|
||||
|
||||
impl NodeDownloader {
|
||||
pub fn new(cache_dir: PathBuf, node_version: String) -> Self {
|
||||
Self {
|
||||
platform: Platform::current(),
|
||||
cache_dir,
|
||||
node_version,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_persistent_cache(node_version: String) -> Result<Self> {
|
||||
let cache_dir = Self::get_persistent_cache_dir()?;
|
||||
Ok(Self {
|
||||
platform: Platform::current(),
|
||||
cache_dir,
|
||||
node_version,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_persistent_cache_dir() -> Result<PathBuf> {
|
||||
let cache_dir = if let Some(cache_home) = std::env::var_os("XDG_CACHE_HOME") {
|
||||
PathBuf::from(cache_home).join("banderole")
|
||||
} else if let Some(home) = std::env::var_os("HOME") {
|
||||
PathBuf::from(home).join(".cache").join("banderole")
|
||||
} else if let Some(appdata) = std::env::var_os("APPDATA") {
|
||||
PathBuf::from(appdata).join("banderole").join("cache")
|
||||
} else {
|
||||
std::env::temp_dir().join("banderole-cache")
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.context("Failed to create persistent cache directory")?;
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
pub async fn ensure_node_binary(&self) -> Result<PathBuf> {
|
||||
// Create cache key for this version and platform
|
||||
let cache_key = format!("{}:{}", self.node_version, self.platform);
|
||||
|
||||
// Check in-memory cache first
|
||||
{
|
||||
let cache = NODE_VERSION_CACHE.lock().map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?;
|
||||
if let Some(cached_path) = cache.get(&cache_key) {
|
||||
if cached_path.exists() {
|
||||
return Ok(cached_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check disk cache
|
||||
let node_dir = self.cache_dir
|
||||
.join("node")
|
||||
.join(&self.node_version)
|
||||
.join(self.platform.to_string());
|
||||
|
||||
let node_executable = node_dir.join(self.platform.node_executable_path());
|
||||
|
||||
if node_executable.exists() {
|
||||
// Update in-memory cache
|
||||
let mut cache = NODE_VERSION_CACHE.lock()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?;
|
||||
cache.insert(cache_key, node_executable.clone());
|
||||
return Ok(node_executable);
|
||||
}
|
||||
|
||||
// Check disk cache
|
||||
let node_dir = self.cache_dir
|
||||
.join("node")
|
||||
.join(&self.node_version)
|
||||
.join(self.platform.to_string());
|
||||
|
||||
let node_executable = node_dir.join(self.platform.node_executable_path());
|
||||
|
||||
if node_executable.exists() {
|
||||
// Update in-memory cache
|
||||
let mut cache = NODE_VERSION_CACHE.lock().map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?;
|
||||
cache.insert(cache_key, node_executable.clone());
|
||||
return Ok(node_executable);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Downloading Node.js {} for {}...",
|
||||
self.node_version, self.platform
|
||||
);
|
||||
|
||||
// Create cache directory
|
||||
fs::create_dir_all(&node_dir)
|
||||
.await
|
||||
.context("Failed to create node cache directory")?;
|
||||
|
||||
// Download and extract Node.js
|
||||
self.download_and_extract_node(&node_dir).await?;
|
||||
|
||||
if !node_executable.exists() {
|
||||
anyhow::bail!(
|
||||
"Node executable not found after extraction: {}",
|
||||
node_executable.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Make executable on Unix systems
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&node_executable).await?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&node_executable, perms).await?;
|
||||
}
|
||||
|
||||
Ok(node_executable)
|
||||
}
|
||||
|
||||
async fn download_and_extract_node(&self, target_dir: &Path) -> Result<()> {
|
||||
let archive_name = self.platform.node_archive_name(&self.node_version);
|
||||
let url = format!(
|
||||
"https://nodejs.org/dist/v{}/{}",
|
||||
self.node_version, archive_name
|
||||
);
|
||||
|
||||
// Download the archive
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.context("Failed to download Node.js archive")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Failed to download Node.js: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let archive_path = target_dir.join(&archive_name);
|
||||
let mut file = fs::File::create(&archive_path)
|
||||
.await
|
||||
.context("Failed to create archive file")?;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("Failed to read download chunk")?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.context("Failed to write archive chunk")?;
|
||||
}
|
||||
|
||||
file.flush().await.context("Failed to flush archive file")?;
|
||||
drop(file);
|
||||
|
||||
// Extract the archive
|
||||
if self.platform.is_windows() {
|
||||
self.extract_zip(&archive_path, target_dir).await?;
|
||||
} else {
|
||||
self.extract_tar_gz(&archive_path, target_dir).await?;
|
||||
}
|
||||
|
||||
// Clean up archive
|
||||
fs::remove_file(&archive_path)
|
||||
.await
|
||||
.context("Failed to remove archive file")?;
|
||||
|
||||
// Update in-memory cache with the path to the node executable
|
||||
let node_executable_path = target_dir.join(self.platform.node_executable_path());
|
||||
let mut cache = NODE_VERSION_CACHE.lock()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?;
|
||||
cache.insert(
|
||||
format!("{}:{}", self.node_version, self.platform),
|
||||
node_executable_path.clone()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn extract_zip(&self, archive_path: &Path, target_dir: &Path) -> Result<()> {
|
||||
let file = std::fs::File::open(archive_path).context("Failed to open zip archive")?;
|
||||
|
||||
let mut archive = zip::ZipArchive::new(file).context("Failed to read zip archive")?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
|
||||
|
||||
let outpath = match file.enclosed_name() {
|
||||
Some(path) => {
|
||||
// Remove the top-level directory from the path
|
||||
let components: Vec<_> = path.components().collect();
|
||||
if components.len() > 1 {
|
||||
target_dir.join(components[1..].iter().collect::<PathBuf>())
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath)
|
||||
.await
|
||||
.context("Failed to create directory")?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
fs::create_dir_all(p)
|
||||
.await
|
||||
.context("Failed to create parent directory")?;
|
||||
}
|
||||
|
||||
let mut outfile = fs::File::create(&outpath)
|
||||
.await
|
||||
.context("Failed to create output file")?;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
std::io::copy(&mut file, &mut buffer).context("Failed to read zip entry")?;
|
||||
|
||||
outfile
|
||||
.write_all(&buffer)
|
||||
.await
|
||||
.context("Failed to write output file")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn extract_tar_gz(&self, archive_path: &Path, target_dir: &Path) -> Result<()> {
|
||||
let output = tokio::process::Command::new("tar")
|
||||
.args(&[
|
||||
"-xzf",
|
||||
archive_path.to_str().unwrap(),
|
||||
"-C",
|
||||
target_dir.to_str().unwrap(),
|
||||
"--strip-components=1",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute tar command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"tar extraction failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
LinuxX64,
|
||||
LinuxArm64,
|
||||
MacosX64,
|
||||
MacosArm64,
|
||||
WindowsX64,
|
||||
WindowsArm64,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn current() -> Self {
|
||||
let os = env::consts::OS;
|
||||
let arch = env::consts::ARCH;
|
||||
|
||||
match (os, arch) {
|
||||
("linux", "x86_64") => Platform::LinuxX64,
|
||||
("linux", "aarch64") => Platform::LinuxArm64,
|
||||
("macos", "x86_64") => Platform::MacosX64,
|
||||
("macos", "aarch64") => Platform::MacosArm64,
|
||||
("windows", "x86_64") => Platform::WindowsX64,
|
||||
("windows", "aarch64") => Platform::WindowsArm64,
|
||||
_ => panic!("Unsupported platform: {}-{}", os, arch),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn node_archive_name(&self, version: &str) -> String {
|
||||
match self {
|
||||
Platform::LinuxX64 => format!("node-v{}-linux-x64.tar.gz", version),
|
||||
Platform::LinuxArm64 => format!("node-v{}-linux-arm64.tar.gz", version),
|
||||
Platform::MacosX64 => format!("node-v{}-darwin-x64.tar.gz", version),
|
||||
Platform::MacosArm64 => format!("node-v{}-darwin-arm64.tar.gz", version),
|
||||
Platform::WindowsX64 => format!("node-v{}-win-x64.zip", version),
|
||||
Platform::WindowsArm64 => format!("node-v{}-win-arm64.zip", version),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn node_executable_path(&self) -> PathBuf {
|
||||
match self {
|
||||
Platform::WindowsX64 | Platform::WindowsArm64 => PathBuf::from("node.exe"),
|
||||
_ => PathBuf::from("bin").join("node"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_windows(&self) -> bool {
|
||||
matches!(self, Platform::WindowsX64 | Platform::WindowsArm64)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Platform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::LinuxX64 => write!(f, "linux-x64"),
|
||||
Self::LinuxArm64 => write!(f, "linux-arm64"),
|
||||
Self::MacosX64 => write!(f, "darwin-x64"),
|
||||
Self::MacosArm64 => write!(f, "darwin-arm64"),
|
||||
Self::WindowsX64 => write!(f, "win32-x64"),
|
||||
Self::WindowsArm64 => write!(f, "win32-arm64"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user