From 12fa53a585e6cc1127554783d9497b3b23ee64b7 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 11 Mar 2026 15:37:53 +0100 Subject: [PATCH] feat(pack): bundle and unbundle subcommands for dist packaging Add bundle [--out ] and unbundle [--out ] subcommands to weft-pack. bundle: validates the package, reads app_id from wapp.toml, writes .app.tar.zst to the output directory (default: current dir). Archive root is / so extraction reproduces the package directory. Fails if the archive already exists. unbundle: decompresses and extracts a .app.tar.zst into the output directory (default: current dir). Compression level 0 (zstd default). No symlinks followed. Dependencies added: tar 0.4, zstd 0.13. Test: bundle_and_unbundle_roundtrip. --- crates/weft-pack/Cargo.toml | 2 + crates/weft-pack/src/main.rs | 89 ++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/weft-pack/Cargo.toml b/crates/weft-pack/Cargo.toml index 4d71492..0db5cee 100644 --- a/crates/weft-pack/Cargo.toml +++ b/crates/weft-pack/Cargo.toml @@ -16,3 +16,5 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } sha2 = "0.10" rand = "0.8" hex = "0.4" +tar = "0.4" +zstd = "0.13" diff --git a/crates/weft-pack/src/main.rs b/crates/weft-pack/src/main.rs index 0be724f..160be86 100644 --- a/crates/weft-pack/src/main.rs +++ b/crates/weft-pack/src/main.rs @@ -55,6 +55,23 @@ fn main() -> anyhow::Result<()> { Some("list") => { list_installed(); } + Some("bundle") => { + let dir = args.get(2).context("usage: weft-pack bundle ")?; + let out = args + .windows(2) + .find(|w| w[0] == "--out") + .map(|w| w[1].as_str()); + bundle_package(Path::new(dir), out.map(Path::new))?; + } + Some("unbundle") => { + let archive = args.get(2).context("usage: weft-pack unbundle ")?; + let out = args + .windows(2) + .find(|w| w[0] == "--out") + .map(|w| w[1].as_str()) + .unwrap_or("."); + unbundle_package(Path::new(archive), Path::new(out))?; + } Some("generate-key") => { let out = args.get(2).map(String::as_str).unwrap_or("."); generate_key(Path::new(out))?; @@ -94,6 +111,8 @@ fn main() -> anyhow::Result<()> { eprintln!(" weft-pack install install package to app store"); eprintln!(" weft-pack uninstall remove installed package"); eprintln!(" weft-pack list list installed packages"); + eprintln!(" weft-pack bundle [--out ] create .app.tar.zst archive"); + eprintln!(" weft-pack unbundle [--out ] extract .app.tar.zst"); eprintln!(" weft-pack generate-key [] generate Ed25519 keypair"); eprintln!(" weft-pack sign --key sign package with private key"); eprintln!(" weft-pack verify --key verify package signature"); @@ -338,6 +357,40 @@ fn copy_dir(src: &Path, dst: &Path) -> anyhow::Result<()> { Ok(()) } +fn bundle_package(dir: &Path, out_dir: Option<&Path>) -> anyhow::Result<()> { + let manifest = load_manifest(dir)?; + let app_id = &manifest.package.id; + let archive_name = format!("{app_id}.app.tar.zst"); + let dest_dir = out_dir.unwrap_or_else(|| Path::new(".")); + let archive_path = dest_dir.join(&archive_name); + if archive_path.exists() { + anyhow::bail!("{} already exists", archive_path.display()); + } + let file = std::fs::File::create(&archive_path) + .with_context(|| format!("create {}", archive_path.display()))?; + let encoder = zstd::Encoder::new(file, 0) + .context("create zstd encoder")? + .auto_finish(); + let mut tar = tar::Builder::new(encoder); + tar.follow_symlinks(false); + tar.append_dir_all(app_id, dir) + .with_context(|| format!("append {} to archive", dir.display()))?; + tar.finish().context("finish tar archive")?; + println!("bundled: {}", archive_path.display()); + Ok(()) +} + +fn unbundle_package(archive: &Path, out_dir: &Path) -> anyhow::Result<()> { + let file = + std::fs::File::open(archive).with_context(|| format!("open {}", archive.display()))?; + let decoder = zstd::Decoder::new(file).context("create zstd decoder")?; + let mut tar = tar::Archive::new(decoder); + tar.unpack(out_dir) + .with_context(|| format!("unpack to {}", out_dir.display()))?; + println!("unbundled: {} -> {}", archive.display(), out_dir.display()); + Ok(()) +} + fn collect_bundle_files( base: &Path, dir: &Path, @@ -678,6 +731,42 @@ entry = "ui/index.html" let _ = fs::remove_dir_all(&tmp); } + #[test] + fn bundle_and_unbundle_roundtrip() { + use std::fs; + let id = format!("bundle_{}", std::process::id()); + let src = std::env::temp_dir().join(&id); + let out = std::env::temp_dir().join(format!("{id}_out")); + let ui = src.join("ui"); + let _ = fs::create_dir_all(&ui); + fs::write(src.join("app.wasm"), b"\0asm\x01\0\0\0").unwrap(); + fs::write(ui.join("index.html"), b"").unwrap(); + let app_id = format!("com.example.b{}", std::process::id()); + fs::write( + src.join("wapp.toml"), + format!( + "[package]\nid = \"{app_id}\"\nname = \"B\"\nversion = \"1.0.0\"\n\n\ + [runtime]\nmodule = \"app.wasm\"\n\n[ui]\nentry = \"ui/index.html\"\n" + ), + ) + .unwrap(); + + let _ = fs::create_dir_all(&out); + bundle_package(&src, Some(&out)).unwrap(); + let archive = out.join(format!("{app_id}.app.tar.zst")); + assert!(archive.exists()); + + let unpack = std::env::temp_dir().join(format!("{id}_unpack")); + let _ = fs::create_dir_all(&unpack); + unbundle_package(&archive, &unpack).unwrap(); + assert!(unpack.join(&app_id).join("app.wasm").exists()); + assert!(unpack.join(&app_id).join("ui").join("index.html").exists()); + + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&out); + let _ = fs::remove_dir_all(&unpack); + } + #[test] fn sign_and_verify_roundtrip() { use std::fs;