feat: show progress bar and clearly separate steps

This commit is contained in:
zhom
2025-08-09 23:57:57 +04:00
parent d37c817003
commit 06107d2ded
8 changed files with 1185 additions and 177 deletions
+275 -37
View File
@@ -3,10 +3,15 @@ use crate::node_downloader::NodeDownloader;
use crate::node_version_manager::NodeVersionManager;
use crate::platform::Platform;
use anyhow::{Context, Result};
use console::{style, Emoji};
use indicatif::{HumanDuration, MultiProgress, ProgressBar, ProgressStyle};
use log::{debug, info, warn};
use serde_json::Value;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;
use zip::ZipWriter;
/// Public entry-point used by `main.rs`.
@@ -24,6 +29,7 @@ pub async fn bundle_project(
custom_name: Option<String>,
no_compression: bool,
ignore_cached_versions: bool,
multi: &MultiProgress,
) -> Result<()> {
let project_path = project_path
.canonicalize()
@@ -54,24 +60,62 @@ pub async fn bundle_project(
.await
.unwrap_or_else(|_| "22.17.1".into());
println!(
"Bundling {app_name} v{app_version} using Node.js v{node_version} for {plat}",
info!(
"Preparing build for {app_name} v{app_version} (Node {node_version}, {plat})",
plat = Platform::current()
);
if source_dir != project_path {
println!("Using source directory: {}", source_dir.display());
debug!("Using source directory: {}", source_dir.display());
}
let output_path = resolve_output_path(output_path, &app_name, custom_name.as_deref())?;
// Styles
let spinner_style =
ProgressStyle::with_template("{prefix:.bold.dim} {spinner:.green} {wide_msg}")
.unwrap()
.tick_chars("/|\\- ");
let bar_style = ProgressStyle::with_template(
"{prefix:.bold.dim} {msg}[ {wide_bar} ] {pos}/{len}",
)
.unwrap()
.progress_chars("#>-");
let emoji_prepare = Emoji("🔧", "");
let emoji_bundle = Emoji("📦", "");
let emoji_build = Emoji("⚙️ ", "");
let emoji_done = Emoji("", "");
let started = Instant::now();
// Stage 1: Prepare environment (resolve version + Node ready)
println!(
"{} {} Preparing environment...",
style("[1/3]").bold().dim(),
emoji_prepare
);
let pb_prepare = multi.add(ProgressBar::new_spinner());
pb_prepare.set_style(spinner_style.clone());
let node_downloader = NodeDownloader::new_with_persistent_cache(&node_version).await?;
let node_executable = node_downloader.ensure_node_binary().await?;
let node_executable = node_downloader
.ensure_node_binary_with_progress(Some(&pb_prepare))
.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()));
pb_prepare.finish_and_clear();
// Stage 2: Bundle application into archive
println!(
"{} {} Bundling application...",
style("[2/3]").bold().dim(),
emoji_bundle
);
let pb_bundle = multi.add(ProgressBar::new(0));
pb_bundle.set_style(bar_style.clone());
let mut zip_data: Vec<u8> = Vec::new();
{
@@ -84,20 +128,98 @@ pub async fn bundle_project(
.compression_level(Some(8))
};
add_dir_to_zip_excluding_node_modules(&mut zip, &source_dir, Path::new("app"), opts)?;
// Pre-count app files
let app_files = count_files_in_dir(&source_dir, true, true);
pb_bundle.set_length(app_files);
add_dir_to_zip_excluding_node_modules(
&mut zip,
&source_dir,
Path::new("app"),
opts,
Some(&pb_bundle),
)?;
bundle_dependencies(&mut zip, &project_path, &source_dir, &package_value, opts)?;
// Dependencies will extend the total as we discover them
bundle_dependencies(
&mut zip,
&project_path,
&source_dir,
&package_value,
opts,
Some(&pb_bundle),
)?;
add_dir_to_zip(&mut zip, node_root, Path::new("node"), opts)?;
// Count node runtime files and extend length
let node_files = count_files_in_dir(node_root, false, true);
let new_len = pb_bundle.length().unwrap_or(0) + node_files;
pb_bundle.set_length(new_len);
add_dir_to_zip(
&mut zip,
node_root,
Path::new("node"),
opts,
Some(&pb_bundle),
)?;
zip.finish()?;
}
pb_bundle.finish_and_clear();
executable::create_self_extracting_executable(&output_path, zip_data, &app_name)?;
// Stage 3: Create executable
println!(
"{} {} Building native binary...",
style("[3/3]").bold().dim(),
emoji_build
);
let pb_build = multi.add(ProgressBar::new(0));
// Do not show a determinate bar yet; use a spinner until total is known
pb_build.set_style(spinner_style.clone());
println!("Bundle created at {}", output_path.display());
executable::create_self_extracting_executable_with_progress(
&output_path,
zip_data,
&app_name,
Some(&pb_build),
)?;
pb_build.finish_and_clear();
println!(
"{} Done in {}",
emoji_done,
HumanDuration(started.elapsed())
);
info!("Bundle created at {}", output_path.display());
Ok(())
}
// Count files (and symlinks) in a directory. Optionally exclude top-level node_modules.
fn count_files_in_dir(dir: &Path, exclude_node_modules: bool, follow_links: bool) -> u64 {
let mut count = 0u64;
let walker = if follow_links {
walkdir::WalkDir::new(dir).follow_links(true)
} else {
walkdir::WalkDir::new(dir).follow_links(false)
};
for entry in walker.into_iter().flatten() {
let path = entry.path();
if exclude_node_modules {
if let Ok(rel) = path.strip_prefix(dir) {
if rel
.components()
.next()
.is_some_and(|c| c.as_os_str() == "node_modules")
{
continue;
}
}
}
if entry.file_type().is_file() || entry.file_type().is_symlink() {
count += 1;
}
}
count
}
/// Bundle dependencies with improved package manager support
fn bundle_dependencies<W>(
zip: &mut ZipWriter<W>,
@@ -105,6 +227,7 @@ fn bundle_dependencies<W>(
source_dir: &Path,
_package_value: &Value,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -133,16 +256,16 @@ where
}
}
let deps_result = find_and_bundle_dependencies(zip, project_path, opts)?;
let deps_result = find_and_bundle_dependencies(zip, project_path, opts, progress)?;
if deps_result.dependencies_found {
println!("Bundled dependencies: {}", deps_result.source_description);
debug!("Bundled dependencies: {}", deps_result.source_description);
} else {
println!("Warning: No dependencies found to bundle");
debug!("No dependencies found to bundle");
}
for warning in &deps_result.warnings {
println!("Warning: {warning}");
debug!("{warning}");
}
Ok(())
@@ -159,6 +282,7 @@ fn find_and_bundle_dependencies<W>(
zip: &mut ZipWriter<W>,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<DependenciesResult>
where
W: Write + Read + std::io::Seek,
@@ -194,7 +318,7 @@ where
if !is_pnpm_workspace {
match package_manager {
PackageManager::Pnpm => {
bundle_pnpm_dependencies(zip, project_path, opts)?;
bundle_pnpm_dependencies(zip, project_path, opts, progress)?;
return Ok(DependenciesResult {
dependencies_found: true,
source_description: "pnpm dependencies (node_modules + .pnpm)".to_string(),
@@ -207,6 +331,7 @@ where
&project_node_modules,
project_path,
opts,
progress,
)?;
return Ok(DependenciesResult {
dependencies_found: true,
@@ -220,6 +345,7 @@ where
&project_node_modules,
project_path,
opts,
progress,
)?;
return Ok(DependenciesResult {
dependencies_found: true,
@@ -263,7 +389,13 @@ where
match package_manager {
PackageManager::Pnpm => {
bundle_pnpm_workspace_dependencies(zip, parent_path, project_path, opts)?;
bundle_pnpm_workspace_dependencies(
zip,
parent_path,
project_path,
opts,
progress,
)?;
return Ok(DependenciesResult {
dependencies_found: true,
source_description: format!(
@@ -280,6 +412,7 @@ where
parent_path,
project_path,
opts,
progress,
)?;
return Ok(DependenciesResult {
dependencies_found: true,
@@ -357,6 +490,7 @@ fn bundle_pnpm_dependencies<W>(
zip: &mut ZipWriter<W>,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -366,7 +500,18 @@ where
if !pnpm_dir.exists() {
if node_modules_path.exists() {
add_dir_to_zip_no_follow(zip, &node_modules_path, Path::new("app/node_modules"), opts)?;
if let Some(pb) = progress {
pb.set_length(
pb.length().unwrap_or(0) + count_files_in_dir(&node_modules_path, false, false),
);
}
add_dir_to_zip_no_follow(
zip,
&node_modules_path,
Path::new("app/node_modules"),
opts,
progress,
)?;
}
return Ok(());
}
@@ -397,7 +542,7 @@ where
)?;
}
println!(
debug!(
"Bundling {} packages (resolved dependencies) for pnpm project",
resolved_packages.len()
);
@@ -405,16 +550,30 @@ where
zip.add_directory("app/node_modules/", opts)?;
for package_name in &resolved_packages {
if let Err(e) =
copy_pnpm_package_comprehensive(zip, &node_modules_path, &pnpm_dir, package_name, opts)
{
println!("Warning: Failed to copy package {package_name}: {e}");
if let Err(e) = copy_pnpm_package_comprehensive(
zip,
&node_modules_path,
&pnpm_dir,
package_name,
opts,
progress,
) {
warn!("Failed to copy package {package_name}: {e}");
}
}
let bin_dir = node_modules_path.join(".bin");
if bin_dir.exists() {
add_dir_to_zip_no_follow(zip, &bin_dir, Path::new("app/node_modules/.bin"), opts)?;
if let Some(pb) = progress {
pb.set_length(pb.length().unwrap_or(0) + count_files_in_dir(&bin_dir, false, false));
}
add_dir_to_zip_no_follow(
zip,
&bin_dir,
Path::new("app/node_modules/.bin"),
opts,
progress,
)?;
}
let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"];
@@ -594,6 +753,7 @@ fn copy_pnpm_package_comprehensive<W>(
pnpm_dir: &Path,
package_name: &str,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -618,7 +778,12 @@ where
};
if target_path.exists() {
add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts)?;
if let Some(pb) = progress {
pb.set_length(
pb.length().unwrap_or(0) + count_files_in_dir(&target_path, false, false),
);
}
add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts, progress)?;
return Ok(());
}
}
@@ -629,11 +794,18 @@ where
if extracted_name == package_name {
let pnpm_package_path = entry.path().join("node_modules").join(package_name);
if pnpm_package_path.exists() {
if let Some(pb) = progress {
pb.set_length(
pb.length().unwrap_or(0)
+ count_files_in_dir(&pnpm_package_path, false, false),
);
}
add_dir_to_zip_no_follow_skip_parents(
zip,
&pnpm_package_path,
&dest_path,
opts,
progress,
)?;
return Ok(());
}
@@ -650,6 +822,7 @@ fn bundle_node_modules_comprehensive<W>(
node_modules_path: &Path,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -690,7 +863,7 @@ where
)?;
}
println!(
debug!(
"Bundling {} packages (resolved dependencies) for pnpm node_modules",
resolved_packages.len()
);
@@ -704,8 +877,9 @@ where
&pnpm_dir,
package_name,
opts,
progress,
) {
println!("Warning: Failed to copy package {package_name}: {e}");
warn!("Failed to copy package {package_name}: {e}");
}
}
} else {
@@ -719,7 +893,7 @@ where
)?;
}
println!(
debug!(
"Bundling {} packages (resolved dependencies) for regular node_modules",
resolved_packages.len()
);
@@ -727,15 +901,26 @@ where
zip.add_directory("app/node_modules/", opts)?;
for package_name in &resolved_packages {
if let Err(e) = copy_workspace_package(zip, node_modules_path, package_name, opts) {
println!("Warning: Failed to copy package {package_name}: {e}");
if let Err(e) =
copy_workspace_package(zip, node_modules_path, package_name, opts, progress)
{
warn!("Failed to copy package {package_name}: {e}");
}
}
}
let bin_dir = node_modules_path.join(".bin");
if bin_dir.exists() {
add_dir_to_zip_no_follow(zip, &bin_dir, Path::new("app/node_modules/.bin"), opts)?;
if let Some(pb) = progress {
pb.set_length(pb.length().unwrap_or(0) + count_files_in_dir(&bin_dir, false, false));
}
add_dir_to_zip_no_follow(
zip,
&bin_dir,
Path::new("app/node_modules/.bin"),
opts,
progress,
)?;
}
let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"];
@@ -746,6 +931,9 @@ where
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
let data = fs::read(&file_path)?;
zip.write_all(&data)?;
if let Some(pb) = progress {
pb.inc(1);
}
}
}
@@ -759,6 +947,7 @@ fn bundle_workspace_dependencies<W>(
_parent_path: &Path,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -796,7 +985,7 @@ where
)?;
}
println!(
debug!(
"Bundling {} packages (resolved dependencies) for workspace node_modules",
resolved_packages.len()
);
@@ -804,14 +993,24 @@ where
zip.add_directory("app/node_modules/", opts)?;
for package_name in &resolved_packages {
if let Err(e) = copy_workspace_package(zip, node_modules_path, package_name, opts) {
println!("Warning: Failed to copy package {package_name}: {e}");
if let Err(e) = copy_workspace_package(zip, node_modules_path, package_name, opts, progress)
{
warn!("Failed to copy package {package_name}: {e}");
}
}
let bin_dir = node_modules_path.join(".bin");
if bin_dir.exists() {
add_dir_to_zip_no_follow(zip, &bin_dir, Path::new("app/node_modules/.bin"), opts)?;
if let Some(pb) = progress {
pb.set_length(pb.length().unwrap_or(0) + count_files_in_dir(&bin_dir, false, false));
}
add_dir_to_zip_no_follow(
zip,
&bin_dir,
Path::new("app/node_modules/.bin"),
opts,
progress,
)?;
}
let important_files = [".modules.yaml"];
@@ -822,6 +1021,9 @@ where
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
let data = fs::read(&file_path)?;
zip.write_all(&data)?;
if let Some(pb) = progress {
pb.inc(1);
}
}
}
@@ -834,6 +1036,7 @@ fn bundle_pnpm_workspace_dependencies<W>(
parent_path: &Path,
project_path: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -872,7 +1075,7 @@ where
)?;
}
println!(
debug!(
"Bundling {} packages (resolved dependencies) for workspace pnpm node_modules",
resolved_packages.len()
);
@@ -887,14 +1090,24 @@ where
&parent_path.join("node_modules").join(".pnpm"),
package_name,
opts,
progress,
) {
println!("Warning: Failed to copy package {package_name}: {e}");
warn!("Failed to copy package {package_name}: {e}");
}
}
let bin_dir = parent_path.join("node_modules").join(".bin");
if bin_dir.exists() {
add_dir_to_zip_no_follow(zip, &bin_dir, Path::new("app/node_modules/.bin"), opts)?;
if let Some(pb) = progress {
pb.set_length(pb.length().unwrap_or(0) + count_files_in_dir(&bin_dir, false, false));
}
add_dir_to_zip_no_follow(
zip,
&bin_dir,
Path::new("app/node_modules/.bin"),
opts,
progress,
)?;
}
let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"];
@@ -905,6 +1118,9 @@ where
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
let data = fs::read(&file_path)?;
zip.write_all(&data)?;
if let Some(pb) = progress {
pb.inc(1);
}
}
}
@@ -1127,6 +1343,7 @@ fn add_dir_to_zip<W>(
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -1164,6 +1381,9 @@ where
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)?;
if let Some(pb) = progress {
pb.inc(1);
}
}
Ok(())
}
@@ -1174,6 +1394,7 @@ fn add_dir_to_zip_no_follow<W>(
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -1219,6 +1440,9 @@ where
let data = fs::read(path).context("Failed to read file while zipping")?;
zip.write_all(&data)?;
}
if let Some(pb) = progress {
pb.inc(1);
}
}
Ok(())
}
@@ -1229,6 +1453,7 @@ fn add_dir_to_zip_no_follow_skip_parents<W>(
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -1276,6 +1501,9 @@ where
let data = fs::read(path).context("Failed to read file while zipping")?;
zip.write_all(&data)?;
}
if let Some(pb) = progress {
pb.inc(1);
}
}
Ok(())
}
@@ -1286,6 +1514,7 @@ fn add_dir_to_zip_excluding_node_modules<W>(
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -1327,6 +1556,9 @@ where
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)?;
if let Some(pb) = progress {
pb.inc(1);
}
}
Ok(())
}
@@ -1337,6 +1569,7 @@ fn copy_workspace_package<W>(
node_modules_path: &Path,
package_name: &str,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
@@ -1358,7 +1591,12 @@ where
};
if target_path.exists() {
add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts)?;
if let Some(pb) = progress {
pb.set_length(
pb.length().unwrap_or(0) + count_files_in_dir(&target_path, false, false),
);
}
add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts, progress)?;
return Ok(());
}
}
+412 -18
View File
@@ -1,4 +1,6 @@
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use log::{error, info};
use std::fs;
use std::path::Path;
use std::process::Command;
@@ -9,40 +11,37 @@ use crate::embedded_template::EmbeddedTemplate;
use crate::platform::Platform;
use crate::rust_toolchain::RustToolchain;
/// Create a cross-platform Rust executable with embedded data
pub fn create_self_extracting_executable(
/// Create a cross-platform Rust executable with embedded data while reporting progress to the provided ProgressBar if any0
pub fn create_self_extracting_executable_with_progress(
output_path: &Path,
zip_data: Vec<u8>,
app_name: &str,
progress: Option<&ProgressBar>,
) -> Result<()> {
// Check if Rust toolchain is available
if let Err(e) = RustToolchain::check_availability() {
eprintln!("\nError: {e}");
eprintln!("{}", RustToolchain::get_installation_instructions());
error!("\nError: {e}");
error!("{}", RustToolchain::get_installation_instructions());
return Err(e);
}
let build_id = Uuid::new_v4().to_string();
// Create temporary directory for building
let temp_dir = TempDir::new().context("Failed to create temporary directory")?;
let build_dir = temp_dir.path();
// Copy template to build directory
copy_template_to_build_dir(build_dir)?;
// Write embedded data
let zip_path = build_dir.join("embedded_data.zip");
fs::write(&zip_path, &zip_data).context("Failed to write embedded zip data")?;
let build_id_path = build_dir.join("build_id.txt");
fs::write(&build_id_path, &build_id).context("Failed to write build ID")?;
// Update Cargo.toml with app name
update_cargo_toml(build_dir, app_name)?;
// Build the executable
build_executable(build_dir, output_path, app_name)?;
info!("Building native binary...");
build_executable_with_progress(build_dir, output_path, app_name, progress)?;
info!("Native binary built");
Ok(())
}
@@ -88,23 +87,263 @@ fn sanitize_package_name(name: &str) -> String {
.to_string()
}
fn build_executable(build_dir: &Path, output_path: &Path, app_name: &str) -> Result<()> {
fn build_executable_with_progress(
build_dir: &Path,
output_path: &Path,
app_name: &str,
progress: Option<&ProgressBar>,
) -> Result<()> {
let current_platform = Platform::current();
let target_triple = get_target_triple(&current_platform);
// Ensure we have the target installed
install_rust_target(&target_triple)?;
// Build the executable
// Do not show a determinate bar until we know the total
// Actual build; consume Cargo JSON messages to compute progress without a dry-run
let mut cmd = Command::new("cargo");
cmd.current_dir(build_dir)
.args(["build", "--release", "--target", &target_triple]);
.args([
"build",
"--release",
"--target",
&target_triple,
"--message-format",
"json",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let output = cmd.output().context("Failed to execute cargo build")?;
let mut child = cmd.spawn().context("Failed to execute cargo build")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Cargo build failed:\n{}", stderr);
// Capture stdout/stderr for diagnostics; parse JSON on stdout for compiled count
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
let stdout_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let stderr_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
// Spawn stdout reader + JSON progress parser
let stdout_arc = Arc::clone(&stdout_buf);
let pb_for_stdout = progress.cloned();
let compiled_count = Arc::new(AtomicU64::new(0));
let compiled_for_stdout = Arc::clone(&compiled_count);
// Determine total crates using cargo metadata (no dry run, no stderr parsing)
// Determine total first, before spawning cargo; don't show bar until known
let known_total: u64 = compute_total_via_cargo_metadata(build_dir, &target_triple).unwrap_or(0);
// Determine total compile units using cargo metadata; only then show a determinate bar
if let Some(pb) = progress {
if known_total > 0 {
pb.set_style(
ProgressStyle::with_template(
"[ {wide_bar} ] {pos}/{len}",
)
.unwrap()
.progress_chars("#>-"),
);
pb.set_length(known_total);
pb.set_position(0);
}
}
let stdout_handle = child.stdout.take().map(|stdout| {
std::thread::spawn(move || {
use std::io::{BufRead, BufReader};
let reader = BufReader::new(stdout);
let mut total_artifacts: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut compiled_artifacts: std::collections::HashSet<String> =
std::collections::HashSet::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if let Ok(mut buf) = stdout_arc.lock() {
buf.push_str(&line);
buf.push('\n');
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
if let Some(reason) = value.get("reason").and_then(|r| r.as_str()) {
if reason == "compiler-artifact" {
let pkg = value
.get("package_id")
.and_then(|p| p.as_str())
.unwrap_or("");
let target_name = value
.get("target")
.and_then(|t| t.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("");
let key = format!("{pkg}:{target_name}");
total_artifacts.insert(key.clone());
let is_fresh = value
.get("fresh")
.and_then(|f| f.as_bool())
.unwrap_or(false);
if !is_fresh {
compiled_artifacts.insert(key);
}
// Update compiled counter and progress position
let compiled_now = compiled_artifacts.len() as u64;
compiled_for_stdout.store(compiled_now, Ordering::SeqCst);
if let Some(pb) = &pb_for_stdout {
let total_len = known_total;
if total_len > 0 && pb.length().unwrap_or(0) != total_len {
pb.set_length(total_len);
}
let pos = if total_len > 0 {
compiled_now.min(total_len)
} else {
compiled_now
};
pb.set_position(pos);
}
}
}
}
}
})
});
// Spawn stderr reader (diagnostics only)
let stderr_arc = Arc::clone(&stderr_buf);
let stderr_handle = child.stderr.take().map(|stderr| {
std::thread::spawn(move || {
use std::io::Read;
let mut reader = std::io::BufReader::new(stderr);
let mut capture_bytes: Vec<u8> = Vec::new();
let _ = reader.read_to_end(&mut capture_bytes);
if let Ok(mut buf) = stderr_arc.lock() {
match String::from_utf8(capture_bytes) {
Ok(s) => buf.push_str(&s),
Err(_) => buf.push_str("<non-utf8 stderr>"),
}
}
})
});
// Consume JSON messages from stdout; estimate total as number of artifacts and compiled as non-fresh artifacts
if let Some(stdout) = child.stdout.take() {
use std::io::{BufRead, BufReader};
let reader = BufReader::new(stdout);
let mut total_artifacts: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut compiled_artifacts: std::collections::HashSet<String> =
std::collections::HashSet::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let Some(reason) = value.get("reason").and_then(|r| r.as_str()) else {
continue;
};
match reason {
"compiler-artifact" => {
let pkg = value
.get("package_id")
.and_then(|p| p.as_str())
.unwrap_or("");
let tname = value
.get("target")
.and_then(|t| t.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("");
let key = format!("{pkg}:{tname}");
total_artifacts.insert(key.clone());
let is_fresh = value
.get("fresh")
.and_then(|f| f.as_bool())
.unwrap_or(false);
if !is_fresh {
compiled_artifacts.insert(key);
}
if let Some(pb) = progress {
let total_now = total_artifacts.len() as u64;
if total_now > 0 && pb.length().unwrap_or(0) != total_now {
pb.set_length(total_now);
}
let compiled_now = compiled_artifacts.len() as u64;
if compiled_now <= pb.length().unwrap_or(0) {
pb.set_position(compiled_now);
}
if let Some(name) = value
.get("target")
.and_then(|t| t.get("name"))
.and_then(|n| n.as_str())
{
let len = pb.length().unwrap_or(0);
let pos = pb.position();
if len > 1 {
pb.set_message(format!("Compiling binary: {name} ({pos}/{len})"));
} else {
pb.set_message(format!("Compiling binary: {name}"));
}
}
}
}
"build-finished" => {
if let Some(pb) = progress {
if let Some(len) = pb.length() {
if len > 0 {
pb.set_position(len);
}
}
}
}
_ => {}
}
}
}
let status = child.wait().context("Failed to wait for cargo build")?;
if let Some(h) = stdout_handle {
let _ = h.join();
}
if let Some(h) = stderr_handle {
let _ = h.join();
}
if !status.success() {
let out = stdout_buf
.lock()
.ok()
.map(|s| s.clone())
.unwrap_or_default();
let err = stderr_buf
.lock()
.ok()
.map(|s| s.clone())
.unwrap_or_default();
let trim_tail = |mut s: String| {
const MAX: usize = 4000;
if s.len() > MAX {
s.split_off(s.len() - MAX)
} else {
String::new()
};
if s.len() > MAX {
s[s.len() - MAX..].to_string()
} else {
s
}
};
let out_tail = trim_tail(out);
let err_tail = trim_tail(err);
anyhow::bail!(
"Cargo build failed.\nLast stdout:\n{}\nLast stderr:\n{}",
out_tail,
err_tail
);
}
// Get the sanitized package name to find the correct executable
@@ -148,6 +387,161 @@ fn build_executable(build_dir: &Path, output_path: &Path, app_name: &str) -> Res
Ok(())
}
fn compute_total_via_cargo_metadata(build_dir: &Path, target_triple: &str) -> Result<u64> {
// Strategy: union of host + target resolve nodes, then count compile-relevant targets per package
// Relevant targets: lib, proc-macro, custom-build for all packages; bin only for the root package
fn run_metadata(build_dir: &Path, args: &[&str]) -> Result<serde_json::Value> {
let output = Command::new("cargo")
.current_dir(build_dir)
.args(args)
.output()
.with_context(|| format!("Failed to run cargo {}", args.join(" ")))?;
if !output.status.success() {
anyhow::bail!(
"cargo {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
let v: serde_json::Value = serde_json::from_slice(&output.stdout)
.context("Failed to parse cargo metadata JSON")?;
Ok(v)
}
fn get_host_triple() -> Result<String> {
let output = Command::new("rustc")
.arg("-vV")
.output()
.context("Failed to run rustc -vV")?;
if !output.status.success() {
anyhow::bail!(
"rustc -vV failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix("host: ") {
return Ok(rest.trim().to_string());
}
}
anyhow::bail!("Failed to parse host triple from rustc -vV")
}
// Run three metadata queries: target-filtered, host-filtered, and unfiltered for packages map
let meta_target = run_metadata(
build_dir,
&[
"metadata",
"--format-version",
"1",
"--filter-platform",
target_triple,
],
)?;
let host_triple = get_host_triple().unwrap_or_else(|_| target_triple.to_string());
let meta_host = run_metadata(
build_dir,
&[
"metadata",
"--format-version",
"1",
"--filter-platform",
&host_triple,
],
)?;
let meta_all = run_metadata(build_dir, &["metadata", "--format-version", "1"])?;
// Collect union of package ids to be considered
let mut pkg_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let push_ids = |val: &serde_json::Value, set: &mut std::collections::HashSet<String>| {
if let Some(nodes) = val
.get("resolve")
.and_then(|r| r.get("nodes"))
.and_then(|n| n.as_array())
{
for node in nodes {
if let Some(id) = node.get("id").and_then(|i| i.as_str()) {
set.insert(id.to_string());
}
}
}
};
push_ids(&meta_target, &mut pkg_ids);
push_ids(&meta_host, &mut pkg_ids);
// Build package map from unfiltered metadata
let mut packages_by_id: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
if let Some(packages) = meta_all.get("packages").and_then(|p| p.as_array()) {
for p in packages {
if let Some(id) = p.get("id").and_then(|i| i.as_str()) {
packages_by_id.insert(id.to_string(), p.clone());
}
}
}
// Root package id
let root_id = meta_target
.get("resolve")
.and_then(|r| r.get("root"))
.and_then(|r| r.as_str())
.or_else(|| {
meta_all
.get("resolve")
.and_then(|r| r.get("root"))
.and_then(|r| r.as_str())
})
.map(|s| s.to_string());
let mut total_units: u64 = 0;
for pid in pkg_ids {
let Some(pkg) = packages_by_id.get(&pid) else {
continue;
};
let is_root = root_id.as_ref().is_some_and(|r| r == &pid);
if let Some(targets) = pkg.get("targets").and_then(|t| t.as_array()) {
for t in targets {
let has_kind = |name: &str| -> bool {
t.get("kind")
.and_then(|k| k.as_array())
.is_some_and(|kinds| kinds.iter().any(|v| v.as_str() == Some(name)))
};
if has_kind("custom-build") {
total_units += 1;
continue;
}
if has_kind("proc-macro") {
total_units += 1;
continue;
}
if has_kind("lib") {
total_units += 1;
continue;
}
if is_root && has_kind("bin") {
total_units += 1;
continue;
}
}
}
}
if total_units == 0 {
// Fallback to node counts if our logic fails
let nodes_len = meta_target
.get("resolve")
.and_then(|r| r.get("nodes"))
.and_then(|n| n.as_array())
.map(|a| a.len() as u64)
.unwrap_or(1);
return Ok(nodes_len.max(1));
}
Ok(total_units)
}
fn get_target_triple(platform: &Platform) -> String {
match platform {
Platform::MacosX64 => "x86_64-apple-darwin".to_string(),
+25 -2
View File
@@ -7,6 +7,9 @@ mod platform;
mod rust_toolchain;
use clap::{Parser, Subcommand};
use indicatif::MultiProgress;
use indicatif_log_bridge::LogWrapper;
use log::LevelFilter;
use std::path::PathBuf;
#[derive(Parser)]
@@ -17,6 +20,9 @@ use std::path::PathBuf;
long_about = "Banderole packages Node.js applications with portable Node binaries into a single binary for easy distribution and execution"
)]
struct Cli {
/// Enable verbose output
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
@@ -44,8 +50,18 @@ enum Commands {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize env_logger wrapped by indicatif's log bridge so logs play nice with progress bars
let multi_progress = MultiProgress::new();
let cli = Cli::parse();
let default_level = if cli.verbose { "debug" } else { "warn" };
let built_logger =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level))
.build();
let level: LevelFilter = built_logger.filter();
LogWrapper::new(multi_progress.clone(), built_logger).try_init()?;
log::set_max_level(level);
match cli.command {
Commands::Bundle {
path,
@@ -54,8 +70,15 @@ async fn main() -> anyhow::Result<()> {
no_compression,
ignore_cached_versions,
} => {
bundler::bundle_project(path, output, name, no_compression, ignore_cached_versions)
.await?;
bundler::bundle_project(
path,
output,
name,
no_compression,
ignore_cached_versions,
&multi_progress,
)
.await?;
}
}
+206 -69
View File
@@ -2,7 +2,9 @@ use crate::node_version_manager::NodeVersionManager;
use crate::platform::Platform;
use anyhow::{Context, Result};
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use lazy_static::lazy_static;
use log::info;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -25,14 +27,17 @@ impl NodeDownloader {
let version_resolver = NodeVersionManager::new();
// Resolve the version specification to a concrete version
let resolved_version = version_resolver
.resolve_version(version_spec, false)
.await
.context(format!(
"Failed to resolve Node.js version '{version_spec}'"
))?;
let resolved_version = match parse_full_version_spec(version_spec) {
Some(full) => full,
None => version_resolver
.resolve_version(version_spec, false)
.await
.context(format!(
"Failed to resolve Node.js version '{version_spec}'"
))?,
};
println!("Resolved '{version_spec}' to Node.js version {resolved_version}");
info!("Resolved '{version_spec}' to Node.js version {resolved_version}");
Ok(Self {
platform: Platform::current(),
@@ -58,7 +63,15 @@ impl NodeDownloader {
Ok(cache_dir)
}
pub async fn ensure_node_binary(&self) -> Result<PathBuf> {
/// Same as ensure_node_binary but reports progress to the provided ProgressBar if any
pub async fn ensure_node_binary_with_progress(
&self,
progress: Option<&ProgressBar>,
) -> Result<PathBuf> {
self.ensure_node_binary_inner(progress).await
}
async fn ensure_node_binary_inner(&self, progress: Option<&ProgressBar>) -> Result<PathBuf> {
// Create cache key for this version and platform
let cache_key = format!("{}:{}", self.node_version, self.platform);
@@ -92,8 +105,8 @@ impl NodeDownloader {
return Ok(node_executable);
}
println!(
"Downloading Node.js {} for {}...",
info!(
"Fetching Node.js {} for {}",
self.node_version, self.platform
);
@@ -103,7 +116,7 @@ impl NodeDownloader {
.context("Failed to create node cache directory")?;
// Download and extract Node.js
self.download_and_extract_node(&node_dir).await?;
self.download_and_extract_node(&node_dir, progress).await?;
if !node_executable.exists() {
anyhow::bail!(
@@ -124,7 +137,11 @@ impl NodeDownloader {
Ok(node_executable)
}
async fn download_and_extract_node(&self, target_dir: &Path) -> Result<()> {
async fn download_and_extract_node(
&self,
target_dir: &Path,
progress: Option<&ProgressBar>,
) -> Result<()> {
let archive_name = self.platform.node_archive_name(&self.node_version);
let url = format!(
"https://nodejs.org/dist/v{}/{}",
@@ -145,22 +162,64 @@ impl NodeDownloader {
.await
.context("Failed to create archive file")?;
// Configure a download progress bar style like the indicatif example
// Template inspired by download-speed.rs example
if let (Some(pb), Some(total)) = (progress, response.content_length()) {
pb.set_style(
ProgressStyle::with_template(
"[ {wide_bar} ] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
)
.unwrap()
.progress_chars("#>-"),
);
pb.set_length(total);
} else if let Some(pb) = progress {
pb.set_style(
ProgressStyle::with_template(
"[ {wide_bar} ] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
)
.unwrap()
.tick_chars("/|\\- "),
);
}
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")?;
if let Some(pb) = progress {
if pb.length().is_some() {
pb.inc(chunk.len() as u64);
} else {
pb.tick();
}
}
}
file.flush().await.context("Failed to flush archive file")?;
drop(file);
// Extract the archive
// Extract the archive with determinate progress
if let Some(pb) = progress {
pb.set_style(
ProgressStyle::with_template(
"[ {wide_bar} ] {pos}/{len}",
)
.unwrap()
.progress_chars("#>-"),
);
pb.set_length(0);
pb.set_position(0);
}
if self.platform.is_windows() {
self.extract_zip(&archive_path, target_dir).await?;
self.extract_zip(&archive_path, target_dir, progress)
.await?;
} else {
self.extract_tar_gz(&archive_path, target_dir).await?;
self.extract_tar_gz(&archive_path, target_dir, progress)
.await?;
}
// Clean up archive
@@ -178,78 +237,156 @@ impl NodeDownloader {
node_executable_path.clone(),
);
// Let caller finish the progress bar for this step
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")?;
async fn extract_zip(
&self,
archive_path: &Path,
target_dir: &Path,
progress: Option<&ProgressBar>,
) -> Result<()> {
let archive_path = archive_path.to_path_buf();
let target_dir = target_dir.to_path_buf();
let progress = progress.cloned();
tokio::task::spawn_blocking(move || -> 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")?;
let mut archive = zip::ZipArchive::new(file).context("Failed to read zip archive")?;
if let Some(pb) = &progress {
pb.set_length(archive.len() as u64);
pb.set_position(0);
}
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
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 {
let outpath = match file.enclosed_name() {
Some(path) => {
let components: Vec<_> = path.components().collect();
if components.len() > 1 {
target_dir.join(components[1..].iter().collect::<PathBuf>())
} else {
if let Some(pb) = &progress {
pb.inc(1);
}
continue;
}
}
None => {
if let Some(pb) = &progress {
pb.inc(1);
}
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")?;
if file.is_dir() {
std::fs::create_dir_all(&outpath).context("Failed to create directory")?;
} else {
if let Some(p) = outpath.parent() {
std::fs::create_dir_all(p).context("Failed to create parent directory")?;
}
let mut outfile =
std::fs::File::create(&outpath).context("Failed to create output file")?;
std::io::copy(&mut file, &mut outfile)
.context("Failed to extract zip entry")?;
}
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")?;
if let Some(pb) = &progress {
pb.inc(1);
}
}
}
Ok(())
})
.await??;
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")?;
async fn extract_tar_gz(
&self,
archive_path: &Path,
target_dir: &Path,
progress: Option<&ProgressBar>,
) -> Result<()> {
let archive_path = archive_path.to_path_buf();
let target_dir = target_dir.to_path_buf();
let progress = progress.cloned();
if !output.status.success() {
anyhow::bail!(
"tar extraction failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
tokio::task::spawn_blocking(move || -> Result<()> {
use flate2::read::GzDecoder;
use tar::Archive;
// First pass: count entries
let file_for_count =
std::fs::File::open(&archive_path).context("Failed to open tar.gz for counting")?;
let decoder_for_count = GzDecoder::new(file_for_count);
let mut archive_for_count = Archive::new(decoder_for_count);
let mut total_entries: u64 = 0;
for _ in archive_for_count
.entries()
.context("Failed to iterate tar entries")?
{
total_entries += 1;
}
if let Some(pb) = &progress {
pb.set_length(total_entries);
pb.set_position(0);
}
// Second pass: extract
let file = std::fs::File::open(&archive_path).context("Failed to open tar.gz")?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
for entry in archive.entries().context("Failed to iterate tar entries")? {
let mut entry = entry.context("Failed to read tar entry")?;
let path = entry.path().context("Failed to get tar entry path")?;
// Strip the first component from the path
let mut components = path.components();
// discard first component
components.next();
let stripped: PathBuf = components.collect();
if stripped.as_os_str().is_empty() {
if let Some(pb) = &progress {
pb.inc(1);
}
continue;
}
let outpath = target_dir.join(stripped);
if let Some(parent) = outpath.parent() {
std::fs::create_dir_all(parent).ok();
}
entry
.unpack(&outpath)
.context("Failed to unpack tar entry")?;
if let Some(pb) = &progress {
pb.inc(1);
}
}
Ok(())
})
.await??;
Ok(())
}
}
fn parse_full_version_spec(spec: &str) -> Option<String> {
let cleaned = spec.trim().trim_start_matches('v');
let parts: Vec<&str> = cleaned.split('.').collect();
if parts.len() == 3 && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) {
Some(cleaned.to_string())
} else {
None
}
}
+7 -6
View File
@@ -1,4 +1,5 @@
use anyhow::{Context, Result};
use log::{debug, info};
use std::process::Command;
/// Manages Rust toolchain requirements and installation
@@ -13,7 +14,7 @@ impl RustToolchain {
match rustc_output {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
println!("Found Rust compiler: {}", version.trim());
debug!("Found Rust compiler: {}", version.trim());
}
_ => {
return Err(anyhow::anyhow!(
@@ -28,7 +29,7 @@ impl RustToolchain {
match cargo_output {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
println!("Found Cargo: {}", version.trim());
debug!("Found Cargo: {}", version.trim());
}
_ => {
return Err(anyhow::anyhow!(
@@ -43,7 +44,7 @@ impl RustToolchain {
match rustup_output {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
println!("Found rustup: {}", version.trim());
debug!("Found rustup: {}", version.trim());
}
_ => {
return Err(anyhow::anyhow!(
@@ -66,7 +67,7 @@ impl RustToolchain {
let installed_targets = String::from_utf8_lossy(&output.stdout);
if !installed_targets.contains(target) {
println!("Installing Rust target: {target}");
info!("Installing Rust target: {target}");
let install_output = Command::new("rustup")
.args(["target", "add", target])
.output()
@@ -76,9 +77,9 @@ impl RustToolchain {
let stderr = String::from_utf8_lossy(&install_output.stderr);
anyhow::bail!("Failed to install target {}:\n{}", target, stderr);
}
println!("Successfully installed target: {target}");
info!("Successfully installed target: {target}");
} else {
println!("Target {target} is already installed");
debug!("Target {target} is already installed");
}
Ok(())