diff --git a/.vscode/settings.json b/.vscode/settings.json index 53f0f3c..5c92914 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,9 @@ "dtolnay", "enabledelayedexpansion", "ERRORLEVEL", + "flate", "imagename", + "indicatif", "LOCALAPPDATA", "lockfiles", "mktemp", diff --git a/Cargo.lock b/Cargo.lock index 45115e1..c6a2cf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,15 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -45,9 +54,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -75,22 +84,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -143,14 +152,21 @@ dependencies = [ "base64", "chrono", "clap", + "console", "directories", + "env_logger", + "flate2", "futures-util", + "indicatif", + "indicatif-log-bridge", "lazy_static", + "log", "reqwest", "serde", "serde_json", "serial_test", "sha2", + "tar", "tempfile", "tokio", "uuid", @@ -202,9 +218,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" dependencies = [ "jobserver", "libc", @@ -244,9 +260,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.41" +version = "4.5.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" dependencies = [ "clap_builder", "clap_derive", @@ -254,9 +270,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.5.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" dependencies = [ "anstream", "anstyle", @@ -288,6 +304,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.60.2", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -407,6 +436,12 @@ dependencies = [ "syn", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -416,6 +451,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -438,6 +496,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "flate2" version = "1.1.2" @@ -609,9 +679,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "h2" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -628,9 +698,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "heck" @@ -906,6 +976,29 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indicatif-log-bridge" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63703cf9069b85dbe6fe26e1c5230d013dee99d3559cd3d02ba39e099ef7ab02" +dependencies = [ + "indicatif", + "log", +] + [[package]] name = "inout" version = "0.1.4" @@ -954,6 +1047,30 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.33" @@ -982,9 +1099,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libbz2-rs-sys" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775bf80d5878ab7c2b1080b5351a48b2f737d9f6f8b383574eebcc22be0dfccb" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" @@ -1014,12 +1131,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags", "libc", + "redox_syscall", ] [[package]] @@ -1251,6 +1369,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -1298,24 +1431,53 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "redox_syscall" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7251471db004e509f4e75a62cca9435365b5ec7bcdff530d612ac7c87c44a792" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", "thiserror", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "reqwest" version = "0.12.22" @@ -1395,9 +1557,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.30" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069a8df149a16b1a12dcc31497c3396a173844be3cac4bd40c9e7671fef96671" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "once_cell", "rustls-pki-types", @@ -1428,9 +1590,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1522,9 +1684,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", @@ -1599,9 +1761,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -1614,9 +1776,9 @@ checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" @@ -1704,6 +1866,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.20.0" @@ -1768,9 +1941,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.47.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -1819,9 +1992,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -1912,6 +2085,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "unit-prefix" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" + [[package]] name = "untrusted" version = "0.9.0" @@ -2092,6 +2277,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi-util" version = "0.1.9" @@ -2195,7 +2390,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -2216,10 +2411,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -2341,6 +2537,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xattr" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.0" @@ -2419,9 +2625,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", diff --git a/Cargo.toml b/Cargo.toml index 184e3d2..e9ff0b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,13 @@ futures-util = "0.3" chrono = { version = "0.4", features = ["serde"] } tempfile = "3.20" base64 = "0.22" +indicatif = "0.18" +log = "0.4" +env_logger = "0.11" +indicatif-log-bridge = "0.2" +console = "0.16" +flate2 = "1.0" +tar = "0.4" [build-dependencies] reqwest = { version = "0.12", features = ["blocking"] } diff --git a/src/bundler.rs b/src/bundler.rs index 972cc75..9266f89 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -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, 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 = 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( zip: &mut ZipWriter, @@ -105,6 +227,7 @@ fn bundle_dependencies( 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( zip: &mut ZipWriter, project_path: &Path, opts: zip::write::FileOptions<'static, ()>, + progress: Option<&ProgressBar>, ) -> Result 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( zip: &mut ZipWriter, 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( 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( 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( _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( 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( 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( 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( 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( 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( 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(()); } } diff --git a/src/executable.rs b/src/executable.rs index 4c02f4e..8fc70bb 100644 --- a/src/executable.rs +++ b/src/executable.rs @@ -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, 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(¤t_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> = Arc::new(Mutex::new(String::new())); + let stderr_buf: Arc> = 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 = + std::collections::HashSet::new(); + let mut compiled_artifacts: std::collections::HashSet = + 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::(&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 = 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(""), + } + } + }) + }); + + // 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 = + std::collections::HashSet::new(); + let mut compiled_artifacts: std::collections::HashSet = + 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::(&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 { + // 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 { + 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 { + 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 = std::collections::HashSet::new(); + let push_ids = |val: &serde_json::Value, set: &mut std::collections::HashSet| { + 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 = + 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(), diff --git a/src/main.rs b/src/main.rs index d1de88f..a47d990 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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?; } } diff --git a/src/node_downloader.rs b/src/node_downloader.rs index 85837e7..036d113 100644 --- a/src/node_downloader.rs +++ b/src/node_downloader.rs @@ -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 { + /// 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 { + self.ensure_node_binary_inner(progress).await + } + + async fn ensure_node_binary_inner(&self, progress: Option<&ProgressBar>) -> Result { // 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::()) - } 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::()) + } 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 { + 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 + } +} diff --git a/src/rust_toolchain.rs b/src/rust_toolchain.rs index 0579734..9259e64 100644 --- a/src/rust_toolchain.rs +++ b/src/rust_toolchain.rs @@ -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(())