mirror of
https://github.com/zhom/banderole.git
synced 2026-08-14 05:30:19 +02:00
1591 lines
57 KiB
Rust
1591 lines
57 KiB
Rust
use crate::node_downloader::NodeDownloader;
|
||
use crate::platform::Platform;
|
||
use anyhow::{Context, Result};
|
||
use serde_json::Value;
|
||
use std::fs;
|
||
use std::io::{Read, Write};
|
||
use std::path::{Path, PathBuf};
|
||
use uuid::Uuid;
|
||
use base64::Engine as _;
|
||
use zip::ZipWriter;
|
||
|
||
|
||
/// Public entry-point used by `main.rs`.
|
||
///
|
||
/// * `project_path` – path that contains a `package.json`.
|
||
/// * `output_path` – optional path to the produced bundle file. If omitted, an
|
||
/// automatically-generated name is used.
|
||
/// * `custom_name` – optional custom name for the executable.
|
||
///
|
||
/// The implementation uses a simpler, more reliable approach based on Playwright's bundling strategy.
|
||
pub async fn bundle_project(project_path: PathBuf, output_path: Option<PathBuf>, custom_name: Option<String>) -> Result<()> {
|
||
// 1. Validate & canonicalize input directory.
|
||
let project_path = project_path
|
||
.canonicalize()
|
||
.context("Failed to resolve project path")?;
|
||
let pkg_json = project_path.join("package.json");
|
||
anyhow::ensure!(pkg_json.exists(), "package.json not found in {}", project_path.display());
|
||
|
||
// 2. Read `package.json` for name/version and detect project structure
|
||
let package_content = fs::read_to_string(&pkg_json).context("Failed to read package.json")?;
|
||
let package_value: Value = serde_json::from_str(&package_content).context("Failed to parse package.json")?;
|
||
|
||
let (app_name, app_version) = (
|
||
package_value["name"].as_str().unwrap_or("app").to_string(),
|
||
package_value["version"].as_str().unwrap_or("0.0.0").to_string(),
|
||
);
|
||
|
||
// 3. Determine the correct source directory to bundle
|
||
let source_dir = determine_source_directory(&project_path, &package_value)?;
|
||
|
||
// 4. Determine Node version (via .nvmrc / .node-version or default to LTS 22.17.1).
|
||
let node_version = detect_node_version(&project_path).unwrap_or_else(|_| "22.17.1".into());
|
||
|
||
println!(
|
||
"Bundling {app_name} v{app_version} using Node.js v{node_version} for {plat}",
|
||
plat = Platform::current()
|
||
);
|
||
|
||
if source_dir != project_path {
|
||
println!("Using source directory: {}", source_dir.display());
|
||
}
|
||
|
||
// 5. Resolve output path with collision handling
|
||
let output_path = resolve_output_path(output_path, &app_name, custom_name.as_deref())?;
|
||
|
||
// 6. Ensure portable Node binary is available.
|
||
let node_downloader = NodeDownloader::new_with_persistent_cache(node_version.clone())?;
|
||
let node_executable = node_downloader.ensure_node_binary().await?;
|
||
let node_root = node_executable
|
||
.parent()
|
||
.expect("node executable must have a parent")
|
||
.parent()
|
||
.unwrap_or_else(|| panic!("Unexpected node layout for {}", node_executable.display()));
|
||
|
||
// 7. Create an in-memory zip archive containing `/app` and `/node` directories.
|
||
let mut zip_data: Vec<u8> = Vec::new();
|
||
{
|
||
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut zip_data));
|
||
let opts: zip::write::FileOptions<'static, ()> = zip::write::FileOptions::default()
|
||
.compression_method(zip::CompressionMethod::Deflated)
|
||
.compression_level(Some(8));
|
||
|
||
// Copy the determined source directory (excluding node_modules to avoid conflicts)
|
||
add_dir_to_zip_excluding_node_modules(&mut zip, &source_dir, Path::new("app"), opts)?;
|
||
|
||
// Handle dependencies and package.json
|
||
bundle_dependencies(&mut zip, &project_path, &source_dir, &package_value, opts)?;
|
||
|
||
// Copy Node runtime directory.
|
||
add_dir_to_zip(&mut zip, node_root, Path::new("node"), opts)?;
|
||
zip.finish()?;
|
||
}
|
||
|
||
// 8. Build self-extracting launcher using a more reliable approach.
|
||
create_self_extracting_executable(&output_path, zip_data, &app_name)?;
|
||
|
||
println!("Bundle created at {}", output_path.display());
|
||
Ok(())
|
||
}
|
||
|
||
/// Bundle dependencies with improved package manager support
|
||
fn bundle_dependencies<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
project_path: &Path,
|
||
source_dir: &Path,
|
||
_package_value: &Value,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
// If we're using a subdirectory, copy the root package.json with adjusted paths
|
||
if source_dir != project_path {
|
||
let root_package_json = project_path.join("package.json");
|
||
if root_package_json.exists() {
|
||
zip.start_file("app/package.json", opts)?;
|
||
|
||
// Read and modify package.json to adjust the main path
|
||
let content = fs::read_to_string(&root_package_json).context("Failed to read root package.json")?;
|
||
let mut package_value: Value = serde_json::from_str(&content).context("Failed to parse root package.json")?;
|
||
|
||
// Adjust the main field if it points to the source directory
|
||
if let Some(main) = package_value["main"].as_str() {
|
||
let main_path = project_path.join(main);
|
||
if let Ok(relative_to_source) = main_path.strip_prefix(&source_dir) {
|
||
package_value["main"] = Value::String(relative_to_source.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
|
||
let modified_content = serde_json::to_string_pretty(&package_value)
|
||
.context("Failed to serialize modified package.json")?;
|
||
zip.write_all(modified_content.as_bytes())?;
|
||
}
|
||
}
|
||
|
||
// Find and bundle dependencies with improved package manager support
|
||
let deps_result = find_and_bundle_dependencies(zip, project_path, opts)?;
|
||
|
||
// Log the result
|
||
if deps_result.dependencies_found {
|
||
println!("Bundled dependencies: {}", deps_result.source_description);
|
||
} else {
|
||
println!("Warning: No dependencies found to bundle");
|
||
}
|
||
|
||
// Log any warnings
|
||
for warning in &deps_result.warnings {
|
||
println!("Warning: {}", warning);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
struct DependenciesResult {
|
||
dependencies_found: bool,
|
||
source_description: String,
|
||
warnings: Vec<String>,
|
||
}
|
||
|
||
/// Find and bundle dependencies with support for different package managers and workspace configurations
|
||
fn find_and_bundle_dependencies<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
project_path: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<DependenciesResult>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
let mut warnings = Vec::new();
|
||
|
||
// Strategy 1: Check for node_modules in the project directory
|
||
let project_node_modules = project_path.join("node_modules");
|
||
if project_node_modules.exists() {
|
||
let package_manager = detect_package_manager(&project_node_modules, project_path);
|
||
|
||
// Check if this is a pnpm workspace (symlinks to parent .pnpm)
|
||
let is_pnpm_workspace = if package_manager == PackageManager::Pnpm {
|
||
// Check if the pnpm structure points to a parent directory
|
||
if let Ok(entries) = fs::read_dir(&project_node_modules) {
|
||
entries.flatten().any(|entry| {
|
||
if entry.file_type().ok().map_or(false, |ft| ft.is_symlink()) {
|
||
if let Ok(target) = fs::read_link(entry.path()) {
|
||
let target_str = target.to_string_lossy();
|
||
target_str.contains("/.pnpm/") && target_str.starts_with("../")
|
||
} else {
|
||
false
|
||
}
|
||
} else {
|
||
false
|
||
}
|
||
})
|
||
} else {
|
||
false
|
||
}
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// If it's a pnpm workspace, skip local bundling and go to workspace detection
|
||
if !is_pnpm_workspace {
|
||
match package_manager {
|
||
PackageManager::Pnpm => {
|
||
// For local pnpm, bundle both node_modules and .pnpm if it exists
|
||
bundle_pnpm_dependencies(zip, project_path, opts)?;
|
||
return Ok(DependenciesResult {
|
||
dependencies_found: true,
|
||
source_description: "pnpm dependencies (node_modules + .pnpm)".to_string(),
|
||
warnings,
|
||
});
|
||
}
|
||
PackageManager::Yarn => {
|
||
// For yarn, bundle node_modules with comprehensive dependency resolution
|
||
bundle_node_modules_comprehensive(zip, &project_node_modules, project_path, opts)?;
|
||
return Ok(DependenciesResult {
|
||
dependencies_found: true,
|
||
source_description: "yarn dependencies (node_modules)".to_string(),
|
||
warnings,
|
||
});
|
||
}
|
||
PackageManager::Npm | PackageManager::Unknown => {
|
||
// For npm or unknown, use comprehensive bundling
|
||
bundle_node_modules_comprehensive(zip, &project_node_modules, project_path, opts)?;
|
||
return Ok(DependenciesResult {
|
||
dependencies_found: true,
|
||
source_description: "npm dependencies (node_modules)".to_string(),
|
||
warnings,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Strategy 2: Check for workspace scenario - look in parent directories
|
||
let mut current_path = project_path.parent();
|
||
while let Some(parent_path) = current_path {
|
||
let parent_node_modules = parent_path.join("node_modules");
|
||
let parent_package_json = parent_path.join("package.json");
|
||
|
||
if parent_node_modules.exists() && parent_package_json.exists() {
|
||
// Check if this is a workspace root
|
||
let mut is_workspace = false;
|
||
|
||
// Check package.json for workspace configuration
|
||
if let Ok(content) = fs::read_to_string(&parent_package_json) {
|
||
if let Ok(pkg_value) = serde_json::from_str::<Value>(&content) {
|
||
is_workspace = pkg_value["workspaces"].is_array()
|
||
|| pkg_value["workspaces"]["packages"].is_array()
|
||
|| pkg_value["workspaces"].is_object();
|
||
}
|
||
}
|
||
|
||
// Check for pnpm-workspace.yaml
|
||
let pnpm_workspace_yaml = parent_path.join("pnpm-workspace.yaml");
|
||
if !is_workspace && pnpm_workspace_yaml.exists() {
|
||
is_workspace = true;
|
||
}
|
||
|
||
if is_workspace {
|
||
warnings.push(format!("Found workspace dependencies in parent directory: {}", parent_path.display()));
|
||
|
||
let package_manager = detect_package_manager(&parent_node_modules, parent_path);
|
||
|
||
match package_manager {
|
||
PackageManager::Pnpm => {
|
||
bundle_pnpm_workspace_dependencies(zip, parent_path, project_path, opts)?;
|
||
return Ok(DependenciesResult {
|
||
dependencies_found: true,
|
||
source_description: format!("workspace pnpm dependencies from {}", parent_path.display()),
|
||
warnings,
|
||
});
|
||
}
|
||
_ => {
|
||
bundle_workspace_dependencies(zip, &parent_node_modules, parent_path, project_path, opts)?;
|
||
return Ok(DependenciesResult {
|
||
dependencies_found: true,
|
||
source_description: format!("workspace dependencies from {}", parent_path.display()),
|
||
warnings,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
current_path = parent_path.parent();
|
||
|
||
// Don't go too far up the directory tree
|
||
if parent_path.components().count() < 2 {
|
||
break;
|
||
}
|
||
}
|
||
|
||
Ok(DependenciesResult {
|
||
dependencies_found: false,
|
||
source_description: "no dependencies found".to_string(),
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
enum PackageManager {
|
||
Npm,
|
||
Yarn,
|
||
Pnpm,
|
||
Unknown,
|
||
}
|
||
|
||
/// Detect the package manager based on the node_modules structure and lockfiles
|
||
fn detect_package_manager(node_modules_path: &Path, project_path: &Path) -> PackageManager {
|
||
// Check for pnpm-specific structure
|
||
if node_modules_path.join(".pnpm").exists() {
|
||
return PackageManager::Pnpm;
|
||
}
|
||
|
||
// Check if this is a pnpm workspace (symlinks pointing to parent .pnpm)
|
||
if node_modules_path.exists() {
|
||
if let Ok(entries) = fs::read_dir(node_modules_path) {
|
||
for entry in entries.flatten() {
|
||
if entry.file_type().ok().map_or(false, |ft| ft.is_symlink()) {
|
||
if let Ok(target) = fs::read_link(entry.path()) {
|
||
let target_str = target.to_string_lossy();
|
||
if target_str.contains("/.pnpm/") {
|
||
return PackageManager::Pnpm;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for lockfiles in the project directory
|
||
if project_path.join("pnpm-lock.yaml").exists() {
|
||
return PackageManager::Pnpm;
|
||
}
|
||
|
||
if project_path.join("yarn.lock").exists() {
|
||
return PackageManager::Yarn;
|
||
}
|
||
|
||
if project_path.join("package-lock.json").exists() {
|
||
return PackageManager::Npm;
|
||
}
|
||
|
||
PackageManager::Unknown
|
||
}
|
||
|
||
/// Bundle pnpm dependencies by creating a flattened node_modules structure
|
||
fn bundle_pnpm_dependencies<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
project_path: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
let node_modules_path = project_path.join("node_modules");
|
||
let pnpm_dir = node_modules_path.join(".pnpm");
|
||
|
||
if !pnpm_dir.exists() {
|
||
// If no .pnpm directory, fall back to simple copy
|
||
if node_modules_path.exists() {
|
||
add_dir_to_zip_no_follow(zip, &node_modules_path, Path::new("app/node_modules"), opts)?;
|
||
}
|
||
return Ok(());
|
||
}
|
||
|
||
// For pnpm, use a smarter approach that only includes actually needed packages
|
||
let mut packages_to_bundle = std::collections::HashSet::new();
|
||
|
||
// Start with direct dependencies from package.json
|
||
let package_json_path = project_path.join("package.json");
|
||
if let Ok(package_json_content) = fs::read_to_string(&package_json_path) {
|
||
if let Ok(package_json) = serde_json::from_str::<Value>(&package_json_content) {
|
||
if let Some(deps) = package_json["dependencies"].as_object() {
|
||
for dep_name in deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
// Only include devDependencies if they're actually used in production
|
||
// For now, skip them to keep the bundle smaller
|
||
}
|
||
}
|
||
|
||
// Recursively resolve dependencies for each package
|
||
let mut resolved_packages = std::collections::HashSet::new();
|
||
for package_name in &packages_to_bundle {
|
||
resolve_package_dependencies(
|
||
&node_modules_path,
|
||
&pnpm_dir,
|
||
package_name,
|
||
&mut resolved_packages,
|
||
0, // depth
|
||
)?;
|
||
}
|
||
|
||
println!("Bundling {} packages (resolved dependencies) for pnpm project", resolved_packages.len());
|
||
|
||
// Ensure app/node_modules directory exists
|
||
zip.add_directory("app/node_modules/", opts)?;
|
||
|
||
// Copy each resolved package
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Copy .bin directory if it exists
|
||
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)?;
|
||
}
|
||
|
||
// Copy important pnpm metadata files
|
||
let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"];
|
||
for file_name in important_files {
|
||
let file_path = node_modules_path.join(file_name);
|
||
if file_path.exists() {
|
||
let dest_path = Path::new("app/node_modules").join(file_name);
|
||
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
|
||
let data = fs::read(&file_path)?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Recursively resolve dependencies for a package
|
||
fn resolve_package_dependencies(
|
||
node_modules_path: &Path,
|
||
pnpm_dir: &Path,
|
||
package_name: &str,
|
||
resolved: &mut std::collections::HashSet<String>,
|
||
depth: usize,
|
||
) -> Result<()> {
|
||
// Avoid infinite recursion
|
||
if depth > 20 {
|
||
return Ok(());
|
||
}
|
||
|
||
// If already resolved, skip
|
||
if resolved.contains(package_name) {
|
||
return Ok(());
|
||
}
|
||
|
||
resolved.insert(package_name.to_string());
|
||
|
||
// Try to find the package and read its package.json
|
||
let package_json_content = match find_package_json_content(node_modules_path, pnpm_dir, package_name) {
|
||
Ok(content) => content,
|
||
Err(_) => return Ok(()), // Skip packages we can't find
|
||
};
|
||
|
||
if let Ok(package_json) = serde_json::from_str::<Value>(&package_json_content) {
|
||
// Add production dependencies
|
||
if let Some(deps) = package_json["dependencies"].as_object() {
|
||
for dep_name in deps.keys() {
|
||
resolve_package_dependencies(node_modules_path, pnpm_dir, dep_name, resolved, depth + 1)?;
|
||
}
|
||
}
|
||
|
||
// Also include peerDependencies that are actually installed
|
||
if let Some(peer_deps) = package_json["peerDependencies"].as_object() {
|
||
for dep_name in peer_deps.keys() {
|
||
// Only include if it actually exists
|
||
if package_exists_in_pnpm(node_modules_path, pnpm_dir, dep_name) {
|
||
resolve_package_dependencies(node_modules_path, pnpm_dir, dep_name, resolved, depth + 1)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also include optionalDependencies that are actually installed (important for native bindings)
|
||
if let Some(optional_deps) = package_json["optionalDependencies"].as_object() {
|
||
for dep_name in optional_deps.keys() {
|
||
// Only include if it actually exists
|
||
if package_exists_in_pnpm(node_modules_path, pnpm_dir, dep_name) {
|
||
resolve_package_dependencies(node_modules_path, pnpm_dir, dep_name, resolved, depth + 1)?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Find package.json content for a package
|
||
fn find_package_json_content(
|
||
node_modules_path: &Path,
|
||
pnpm_dir: &Path,
|
||
package_name: &str,
|
||
) -> Result<String> {
|
||
// First try top-level
|
||
let top_level_package = node_modules_path.join(package_name);
|
||
if top_level_package.exists() {
|
||
let target_path = if top_level_package.is_symlink() {
|
||
let target = fs::read_link(&top_level_package)?;
|
||
if target.is_absolute() {
|
||
target
|
||
} else {
|
||
top_level_package.parent().unwrap().join(target).canonicalize()?
|
||
}
|
||
} else {
|
||
top_level_package
|
||
};
|
||
|
||
let package_json_path = target_path.join("package.json");
|
||
if package_json_path.exists() {
|
||
return fs::read_to_string(&package_json_path).context("Failed to read package.json");
|
||
}
|
||
}
|
||
|
||
// Try .pnpm directory
|
||
for entry in fs::read_dir(pnpm_dir)? {
|
||
let entry = entry?;
|
||
let pnpm_package_name = entry.file_name().to_string_lossy().to_string();
|
||
|
||
if let Some(extracted_name) = extract_package_name_from_pnpm(&pnpm_package_name) {
|
||
if extracted_name == package_name {
|
||
let pnpm_package_path = entry.path().join("node_modules").join(package_name);
|
||
let package_json_path = pnpm_package_path.join("package.json");
|
||
if package_json_path.exists() {
|
||
return fs::read_to_string(&package_json_path).context("Failed to read package.json");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
anyhow::bail!("Could not find package.json for {}", package_name)
|
||
}
|
||
|
||
/// Check if a package exists in the pnpm structure
|
||
fn package_exists_in_pnpm(
|
||
node_modules_path: &Path,
|
||
pnpm_dir: &Path,
|
||
package_name: &str,
|
||
) -> bool {
|
||
// Check top-level
|
||
if node_modules_path.join(package_name).exists() {
|
||
return true;
|
||
}
|
||
|
||
// Check .pnpm
|
||
if let Ok(entries) = fs::read_dir(pnpm_dir) {
|
||
for entry in entries.flatten() {
|
||
let pnpm_package_name = entry.file_name().to_string_lossy().to_string();
|
||
if let Some(extracted_name) = extract_package_name_from_pnpm(&pnpm_package_name) {
|
||
if extracted_name == package_name {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Extract package name from pnpm directory name (e.g., "adm-zip@0.5.16" -> "adm-zip")
|
||
fn extract_package_name_from_pnpm(pnpm_name: &str) -> Option<String> {
|
||
// Handle scoped packages like "@sindresorhus+is@4.6.0" -> "@sindresorhus/is"
|
||
if pnpm_name.starts_with('@') {
|
||
if let Some(at_pos) = pnpm_name.rfind('@') {
|
||
if at_pos > 0 { // Make sure it's not the first @
|
||
let package_part = &pnpm_name[..at_pos];
|
||
// Convert + back to / for scoped packages
|
||
return Some(package_part.replace('+', "/"));
|
||
}
|
||
}
|
||
// If no version found, just convert + to /
|
||
return Some(pnpm_name.replace('+', "/"));
|
||
}
|
||
|
||
// Handle regular packages like "adm-zip@0.5.16"
|
||
if let Some(at_pos) = pnpm_name.find('@') {
|
||
Some(pnpm_name[..at_pos].to_string())
|
||
} else {
|
||
Some(pnpm_name.to_string())
|
||
}
|
||
}
|
||
|
||
/// Copy a package, trying both top-level and .pnpm locations
|
||
fn copy_pnpm_package_comprehensive<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
node_modules_path: &Path,
|
||
pnpm_dir: &Path,
|
||
package_name: &str,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
let dest_path = Path::new("app/node_modules").join(package_name);
|
||
|
||
// First try to find it as a top-level package
|
||
let top_level_package = node_modules_path.join(package_name);
|
||
if top_level_package.exists() {
|
||
let target_path = if top_level_package.is_symlink() {
|
||
// Follow the symlink
|
||
let target = fs::read_link(&top_level_package)?;
|
||
if target.is_absolute() {
|
||
target
|
||
} else {
|
||
top_level_package.parent().unwrap().join(target).canonicalize()?
|
||
}
|
||
} else {
|
||
top_level_package
|
||
};
|
||
|
||
if target_path.exists() {
|
||
add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts)?;
|
||
return Ok(());
|
||
}
|
||
}
|
||
|
||
// If not found at top level, search in .pnpm directory
|
||
for entry in fs::read_dir(pnpm_dir)? {
|
||
let entry = entry?;
|
||
let pnpm_package_name = entry.file_name().to_string_lossy().to_string();
|
||
|
||
// Check if this .pnpm entry matches our package name
|
||
if let Some(extracted_name) = extract_package_name_from_pnpm(&pnpm_package_name) {
|
||
if extracted_name == package_name {
|
||
let pnpm_package_path = entry.path().join("node_modules").join(package_name);
|
||
if pnpm_package_path.exists() {
|
||
add_dir_to_zip_no_follow_skip_parents(zip, &pnpm_package_path, &dest_path, opts)?;
|
||
return Ok(());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
|
||
|
||
/// Bundle node_modules with comprehensive dependency resolution
|
||
fn bundle_node_modules_comprehensive<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
node_modules_path: &Path,
|
||
project_path: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
let mut packages_to_bundle = std::collections::HashSet::new();
|
||
|
||
// Start with direct dependencies from package.json
|
||
let package_json_path = project_path.join("package.json");
|
||
if let Ok(package_json_content) = fs::read_to_string(&package_json_path) {
|
||
if let Ok(package_json) = serde_json::from_str::<Value>(&package_json_content) {
|
||
if let Some(deps) = package_json["dependencies"].as_object() {
|
||
for dep_name in deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
// Also include peerDependencies and optionalDependencies
|
||
if let Some(peer_deps) = package_json["peerDependencies"].as_object() {
|
||
for dep_name in peer_deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
if let Some(optional_deps) = package_json["optionalDependencies"].as_object() {
|
||
for dep_name in optional_deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if this is a pnpm setup
|
||
let pnpm_dir = node_modules_path.join(".pnpm");
|
||
if pnpm_dir.exists() {
|
||
// Use pnpm-specific resolution
|
||
let mut resolved_packages = std::collections::HashSet::new();
|
||
for package_name in &packages_to_bundle {
|
||
resolve_package_dependencies(
|
||
node_modules_path,
|
||
&pnpm_dir,
|
||
package_name,
|
||
&mut resolved_packages,
|
||
0,
|
||
)?;
|
||
}
|
||
|
||
println!("Bundling {} packages (resolved dependencies) for pnpm node_modules", resolved_packages.len());
|
||
|
||
// Ensure app/node_modules directory exists
|
||
zip.add_directory("app/node_modules/", opts)?;
|
||
|
||
// Copy each resolved package using pnpm logic
|
||
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);
|
||
}
|
||
}
|
||
} else {
|
||
// Use regular workspace resolution for non-pnpm setups
|
||
let mut resolved_packages = std::collections::HashSet::new();
|
||
for package_name in &packages_to_bundle {
|
||
resolve_workspace_dependencies(
|
||
node_modules_path,
|
||
package_name,
|
||
&mut resolved_packages,
|
||
0,
|
||
)?;
|
||
}
|
||
|
||
println!("Bundling {} packages (resolved dependencies) for regular node_modules", resolved_packages.len());
|
||
|
||
// Ensure app/node_modules directory exists
|
||
zip.add_directory("app/node_modules/", opts)?;
|
||
|
||
// Copy each resolved package using workspace logic
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Copy .bin directory if it exists
|
||
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)?;
|
||
}
|
||
|
||
// Copy important metadata files
|
||
let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"];
|
||
for file_name in important_files {
|
||
let file_path = node_modules_path.join(file_name);
|
||
if file_path.exists() {
|
||
let dest_path = Path::new("app/node_modules").join(file_name);
|
||
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
|
||
let data = fs::read(&file_path)?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Bundle workspace dependencies (node_modules from parent)
|
||
fn bundle_workspace_dependencies<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
node_modules_path: &Path,
|
||
_parent_path: &Path,
|
||
project_path: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
let mut packages_to_bundle = std::collections::HashSet::new();
|
||
|
||
// Read dependencies from the ACTUAL PROJECT being bundled, not the workspace root
|
||
let package_json_path = project_path.join("package.json");
|
||
if let Ok(package_json_content) = fs::read_to_string(&package_json_path) {
|
||
if let Ok(package_json) = serde_json::from_str::<Value>(&package_json_content) {
|
||
if let Some(deps) = package_json["dependencies"].as_object() {
|
||
for dep_name in deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
// Also include peerDependencies and optionalDependencies
|
||
if let Some(peer_deps) = package_json["peerDependencies"].as_object() {
|
||
for dep_name in peer_deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
if let Some(optional_deps) = package_json["optionalDependencies"].as_object() {
|
||
for dep_name in optional_deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Recursively resolve dependencies for each package using workspace-specific logic
|
||
let mut resolved_packages = std::collections::HashSet::new();
|
||
for package_name in &packages_to_bundle {
|
||
resolve_workspace_dependencies(
|
||
node_modules_path,
|
||
package_name,
|
||
&mut resolved_packages,
|
||
0, // depth
|
||
)?;
|
||
}
|
||
|
||
println!("Bundling {} packages (resolved dependencies) for workspace node_modules", resolved_packages.len());
|
||
|
||
// Ensure app/node_modules directory exists
|
||
zip.add_directory("app/node_modules/", opts)?;
|
||
|
||
// Copy each resolved package using workspace-specific copying
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Copy .bin directory if it exists
|
||
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)?;
|
||
}
|
||
|
||
// Copy important workspace metadata files if they exist
|
||
let important_files = [".modules.yaml"];
|
||
for file_name in important_files {
|
||
let file_path = node_modules_path.join(file_name);
|
||
if file_path.exists() {
|
||
let dest_path = Path::new("app/node_modules").join(file_name);
|
||
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
|
||
let data = fs::read(&file_path)?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Bundle pnpm workspace dependencies (node_modules from parent)
|
||
fn bundle_pnpm_workspace_dependencies<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
parent_path: &Path,
|
||
project_path: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
let mut packages_to_bundle = std::collections::HashSet::new();
|
||
|
||
// Read dependencies from the ACTUAL PROJECT being bundled, not the workspace root
|
||
let package_json_path = project_path.join("package.json");
|
||
if let Ok(package_json_content) = fs::read_to_string(&package_json_path) {
|
||
if let Ok(package_json) = serde_json::from_str::<Value>(&package_json_content) {
|
||
if let Some(deps) = package_json["dependencies"].as_object() {
|
||
for dep_name in deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
// Also include peerDependencies and optionalDependencies
|
||
if let Some(peer_deps) = package_json["peerDependencies"].as_object() {
|
||
for dep_name in peer_deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
if let Some(optional_deps) = package_json["optionalDependencies"].as_object() {
|
||
for dep_name in optional_deps.keys() {
|
||
packages_to_bundle.insert(dep_name.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Recursively resolve dependencies for each package using pnpm-specific logic
|
||
let mut resolved_packages = std::collections::HashSet::new();
|
||
for package_name in &packages_to_bundle {
|
||
resolve_package_dependencies(
|
||
&parent_path.join("node_modules"),
|
||
&parent_path.join("node_modules").join(".pnpm"),
|
||
package_name,
|
||
&mut resolved_packages,
|
||
0, // depth
|
||
)?;
|
||
}
|
||
|
||
println!("Bundling {} packages (resolved dependencies) for workspace pnpm node_modules", resolved_packages.len());
|
||
|
||
// Ensure app/node_modules directory exists
|
||
zip.add_directory("app/node_modules/", opts)?;
|
||
|
||
// Copy each resolved package using pnpm-specific copying
|
||
for package_name in &resolved_packages {
|
||
if let Err(e) = copy_pnpm_package_comprehensive(zip, &parent_path.join("node_modules"), &parent_path.join("node_modules").join(".pnpm"), package_name, opts) {
|
||
println!("Warning: Failed to copy package {}: {}", package_name, e);
|
||
}
|
||
}
|
||
|
||
// Copy .bin directory if it exists
|
||
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)?;
|
||
}
|
||
|
||
// Copy important pnpm metadata files
|
||
let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"];
|
||
for file_name in important_files {
|
||
let file_path = parent_path.join("node_modules").join(file_name);
|
||
if file_path.exists() {
|
||
let dest_path = Path::new("app/node_modules").join(file_name);
|
||
zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?;
|
||
let data = fs::read(&file_path)?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Very lightweight Node version detection.
|
||
fn detect_node_version(project_path: &Path) -> Result<String> {
|
||
for file in [".nvmrc", ".node-version"] {
|
||
let path = project_path.join(file);
|
||
if path.exists() {
|
||
let v = fs::read_to_string(&path)?;
|
||
return Ok(normalise_node_version(v.trim()));
|
||
}
|
||
}
|
||
anyhow::bail!("Node version not found")
|
||
}
|
||
|
||
fn normalise_node_version(raw: &str) -> String {
|
||
raw.trim_start_matches('v').to_owned()
|
||
}
|
||
|
||
/// Determine the correct source directory to bundle for the project.
|
||
/// This handles TypeScript projects and other build configurations.
|
||
fn determine_source_directory(project_path: &Path, package_json: &Value) -> Result<PathBuf> {
|
||
// Check if there's a specific main entry point that indicates a built project
|
||
if let Some(main) = package_json["main"].as_str() {
|
||
let main_path = project_path.join(main);
|
||
if let Some(parent) = main_path.parent() {
|
||
// If main points to a file in a subdirectory like dist/index.js or build/index.js
|
||
let parent_name = parent.file_name()
|
||
.and_then(|name| name.to_str())
|
||
.unwrap_or("");
|
||
|
||
if ["dist", "build", "lib", "out"].contains(&parent_name) && parent.exists() {
|
||
return Ok(parent.to_path_buf());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for TypeScript configuration
|
||
let tsconfig_path = project_path.join("tsconfig.json");
|
||
if tsconfig_path.exists() {
|
||
if let Ok(tsconfig) = read_tsconfig(&tsconfig_path) {
|
||
if let Some(out_dir) = tsconfig["compilerOptions"]["outDir"].as_str() {
|
||
let out_path = project_path.join(out_dir);
|
||
if out_path.exists() {
|
||
return Ok(out_path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for common build output directories
|
||
for dir_name in ["dist", "build", "lib", "out"] {
|
||
let dir_path = project_path.join(dir_name);
|
||
if dir_path.exists() && dir_path.is_dir() {
|
||
// Verify it contains JavaScript files or a package.json
|
||
if contains_js_files(&dir_path) || dir_path.join("package.json").exists() {
|
||
return Ok(dir_path);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Default to the project root
|
||
Ok(project_path.to_path_buf())
|
||
}
|
||
|
||
/// Read and parse tsconfig.json, handling extends configuration
|
||
fn read_tsconfig(tsconfig_path: &Path) -> Result<Value> {
|
||
let content = fs::read_to_string(tsconfig_path)
|
||
.context("Failed to read tsconfig.json")?;
|
||
|
||
// Remove comments for JSON parsing (simple approach)
|
||
let cleaned_content = content
|
||
.lines()
|
||
.filter(|line| !line.trim_start().starts_with("//"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
let mut config: Value = serde_json::from_str(&cleaned_content)
|
||
.context("Failed to parse tsconfig.json")?;
|
||
|
||
// Handle extends configuration
|
||
if let Some(extends) = config["extends"].as_str() {
|
||
let base_path = if extends.starts_with('.') {
|
||
tsconfig_path.parent().unwrap().join(extends)
|
||
} else {
|
||
// Could be a package reference, but for now we'll skip
|
||
return Ok(config);
|
||
};
|
||
|
||
// Add .json extension if not present
|
||
let base_path = if base_path.extension().is_none() {
|
||
base_path.with_extension("json")
|
||
} else {
|
||
base_path
|
||
};
|
||
|
||
if base_path.exists() {
|
||
if let Ok(base_config) = read_tsconfig(&base_path) {
|
||
// Merge base config with current config (simple merge)
|
||
if let (Some(base_obj), Some(current_obj)) = (base_config.as_object(), config.as_object()) {
|
||
let mut merged = base_obj.clone();
|
||
for (key, value) in current_obj {
|
||
merged.insert(key.clone(), value.clone());
|
||
}
|
||
config = Value::Object(merged);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(config)
|
||
}
|
||
|
||
/// Check if a directory contains JavaScript files
|
||
fn contains_js_files(dir: &Path) -> bool {
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
for entry in entries.flatten() {
|
||
if let Some(name) = entry.file_name().to_str() {
|
||
if name.ends_with(".js") || name.ends_with(".mjs") || name.ends_with(".cjs") {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Resolve the output path, handling naming conflicts
|
||
fn resolve_output_path(
|
||
output_path: Option<PathBuf>,
|
||
app_name: &str,
|
||
custom_name: Option<&str>,
|
||
) -> Result<PathBuf> {
|
||
if let Some(path) = output_path {
|
||
// If explicit output path is provided, use it as-is
|
||
return Ok(path);
|
||
}
|
||
|
||
let ext = if Platform::current().is_windows() { ".exe" } else { "" };
|
||
let base_name = custom_name.unwrap_or(app_name);
|
||
let mut output_path = PathBuf::from(format!("{base_name}{ext}"));
|
||
|
||
// Check for conflicts and resolve them
|
||
let mut counter = 1;
|
||
while output_path.exists() {
|
||
if output_path.is_dir() {
|
||
// If it's a directory, append a suffix
|
||
output_path = PathBuf::from(format!("{base_name}-bundle{ext}"));
|
||
if !output_path.exists() {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// If it still exists (file or another directory), add a counter
|
||
if output_path.exists() {
|
||
output_path = PathBuf::from(format!("{base_name}-bundle-{counter}{ext}"));
|
||
counter += 1;
|
||
}
|
||
}
|
||
|
||
Ok(output_path)
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// Self-extracting executable generation using a more reliable approach
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
fn create_self_extracting_executable(out: &Path, zip_data: Vec<u8>, _app_name: &str) -> Result<()> {
|
||
let build_id = Uuid::new_v4();
|
||
|
||
if Platform::current().is_windows() {
|
||
create_windows_executable(out, zip_data, &build_id.to_string())
|
||
} else {
|
||
create_unix_executable(out, zip_data, &build_id.to_string())
|
||
}
|
||
}
|
||
|
||
fn create_unix_executable(out: &Path, zip_data: Vec<u8>, build_id: &str) -> Result<()> {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let mut file = fs::File::create(out).context("Failed to create output executable")?;
|
||
|
||
// Write a simpler, more reliable shell script
|
||
let script = format!(r#"#!/bin/bash
|
||
set -e
|
||
|
||
# Determine cache directory using directories pattern
|
||
if [ -n "$XDG_CACHE_HOME" ]; then
|
||
CACHE_DIR="$XDG_CACHE_HOME/banderole"
|
||
elif [ -n "$HOME" ]; then
|
||
CACHE_DIR="$HOME/.cache/banderole"
|
||
else
|
||
CACHE_DIR="/tmp/banderole-cache"
|
||
fi
|
||
|
||
APP_DIR="$CACHE_DIR/{}"
|
||
|
||
# Check if already extracted and ready
|
||
if [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then
|
||
# Already extracted, run directly
|
||
cd "$APP_DIR/app"
|
||
|
||
# Find main script from package.json
|
||
MAIN_SCRIPT=$("$APP_DIR/node/bin/node" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2>/dev/null || echo "index.js")
|
||
|
||
if [ -f "$MAIN_SCRIPT" ]; then
|
||
exec "$APP_DIR/node/bin/node" "$MAIN_SCRIPT" "$@"
|
||
else
|
||
echo "Error: Main script '$MAIN_SCRIPT' not found" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
# Extract application
|
||
mkdir -p "$APP_DIR"
|
||
|
||
# Create a temporary file for the zip data
|
||
TEMP_ZIP=$(mktemp)
|
||
trap "rm -f '$TEMP_ZIP'" EXIT
|
||
|
||
# Extract embedded zip data (everything after the __DATA__ marker)
|
||
awk '/^__DATA__$/{{p=1;next}} p{{print}}' "$0" | base64 -d > "$TEMP_ZIP"
|
||
|
||
# Verify we got valid zip data
|
||
if [ ! -s "$TEMP_ZIP" ]; then
|
||
echo "Error: Failed to extract bundle data" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Extract the bundle
|
||
if ! unzip -q "$TEMP_ZIP" -d "$APP_DIR" 2>/dev/null; then
|
||
echo "Error: Failed to extract bundle" >&2
|
||
rm -rf "$APP_DIR"
|
||
exit 1
|
||
fi
|
||
|
||
# Verify extraction worked
|
||
if [ ! -f "$APP_DIR/app/package.json" ] || [ ! -x "$APP_DIR/node/bin/node" ]; then
|
||
echo "Error: Bundle extraction incomplete" >&2
|
||
rm -rf "$APP_DIR"
|
||
exit 1
|
||
fi
|
||
|
||
# Run the application
|
||
cd "$APP_DIR/app"
|
||
MAIN_SCRIPT=$("$APP_DIR/node/bin/node" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2>/dev/null || echo "index.js")
|
||
|
||
if [ -f "$MAIN_SCRIPT" ]; then
|
||
exec "$APP_DIR/node/bin/node" "$MAIN_SCRIPT" "$@"
|
||
else
|
||
echo "Error: Main script '$MAIN_SCRIPT' not found" >&2
|
||
exit 1
|
||
fi
|
||
|
||
__DATA__
|
||
"#, build_id);
|
||
|
||
file.write_all(script.as_bytes())?;
|
||
|
||
// Append base64-encoded zip data
|
||
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
|
||
file.write_all(encoded.as_bytes())?;
|
||
file.write_all(b"\n")?;
|
||
|
||
// Make executable
|
||
let mut perms = file.metadata()?.permissions();
|
||
perms.set_mode(0o755);
|
||
fs::set_permissions(out, perms)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn create_windows_executable(out: &Path, zip_data: Vec<u8>, build_id: &str) -> Result<()> {
|
||
let mut file = fs::File::create(out).context("Failed to create output executable")?;
|
||
|
||
// Create a more reliable Windows batch script
|
||
let script = format!(r#"@echo off
|
||
setlocal enabledelayedexpansion
|
||
|
||
REM Determine cache directory
|
||
set "CACHE_DIR=%LOCALAPPDATA%\banderole"
|
||
set "APP_DIR=!CACHE_DIR!\{}"
|
||
|
||
REM Check if already extracted and ready
|
||
if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" (
|
||
cd /d "!APP_DIR!\app"
|
||
|
||
REM Find main script
|
||
for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i"
|
||
if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js"
|
||
|
||
if exist "!MAIN_SCRIPT!" (
|
||
"!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %*
|
||
exit /b !errorlevel!
|
||
) else (
|
||
echo Error: Main script '!MAIN_SCRIPT!' not found >&2
|
||
exit /b 1
|
||
)
|
||
)
|
||
|
||
REM Extract application
|
||
if not exist "!CACHE_DIR!" mkdir "!CACHE_DIR!"
|
||
if not exist "!APP_DIR!" mkdir "!APP_DIR!"
|
||
|
||
REM Create temp file for zip
|
||
set "TEMP_ZIP=%TEMP%\banderole-bundle-%RANDOM%.zip"
|
||
|
||
REM Extract embedded zip data using PowerShell
|
||
powershell -NoProfile -Command "$content = Get-Content '%~f0' -Raw; $dataStart = $content.IndexOf('__DATA__') + 8; $data = $content.Substring($dataStart).Trim(); [System.IO.File]::WriteAllBytes('%TEMP_ZIP%', [System.Convert]::FromBase64String($data))"
|
||
|
||
if not exist "%TEMP_ZIP%" (
|
||
echo Error: Failed to extract bundle data >&2
|
||
exit /b 1
|
||
)
|
||
|
||
REM Extract the bundle using PowerShell
|
||
powershell -NoProfile -Command "try {{ Expand-Archive -Path '%TEMP_ZIP%' -DestinationPath '!APP_DIR!' -Force }} catch {{ Write-Error $_.Exception.Message; exit 1 }}"
|
||
set "EXTRACT_RESULT=!errorlevel!"
|
||
del "%TEMP_ZIP%" 2>nul
|
||
|
||
if !EXTRACT_RESULT! neq 0 (
|
||
echo Error: Failed to extract bundle >&2
|
||
rmdir /s /q "!APP_DIR!" 2>nul
|
||
exit /b 1
|
||
)
|
||
|
||
REM Verify extraction worked
|
||
if not exist "!APP_DIR!\app\package.json" (
|
||
echo Error: Bundle extraction incomplete >&2
|
||
rmdir /s /q "!APP_DIR!" 2>nul
|
||
exit /b 1
|
||
)
|
||
|
||
if not exist "!APP_DIR!\node\node.exe" (
|
||
echo Error: Node.js executable not found >&2
|
||
rmdir /s /q "!APP_DIR!" 2>nul
|
||
exit /b 1
|
||
)
|
||
|
||
REM Run the application
|
||
cd /d "!APP_DIR!\app"
|
||
for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i"
|
||
if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js"
|
||
|
||
if exist "!MAIN_SCRIPT!" (
|
||
"!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %*
|
||
exit /b !errorlevel!
|
||
) else (
|
||
echo Error: Main script '!MAIN_SCRIPT!' not found >&2
|
||
exit /b 1
|
||
)
|
||
|
||
__DATA__
|
||
"#, build_id);
|
||
|
||
file.write_all(script.as_bytes())?;
|
||
|
||
// Append base64-encoded zip data
|
||
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
|
||
file.write_all(encoded.as_bytes())?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// Utility helpers
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
fn add_dir_to_zip<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
src_dir: &Path,
|
||
dest_dir: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
for entry in walkdir::WalkDir::new(src_dir).follow_links(true) {
|
||
let entry = entry?;
|
||
let path = entry.path();
|
||
let rel_path = path.strip_prefix(src_dir).unwrap();
|
||
let zip_path = dest_dir.join(rel_path);
|
||
|
||
if entry.file_type().is_dir() {
|
||
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
|
||
continue;
|
||
}
|
||
|
||
// Process regular files and symlinks, skip other special files
|
||
if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
|
||
continue;
|
||
}
|
||
|
||
// Get file permissions to preserve executable bits (Unix only)
|
||
let file_opts = {
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let metadata = fs::metadata(path)?;
|
||
let permissions = metadata.permissions();
|
||
let mode = permissions.mode();
|
||
opts.unix_permissions(mode)
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
opts
|
||
}
|
||
};
|
||
|
||
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
|
||
let data = fs::read(path).context("Failed to read file while zipping")?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Add directory to zip without following symlinks but preserving them
|
||
fn add_dir_to_zip_no_follow<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
src_dir: &Path,
|
||
dest_dir: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
for entry in walkdir::WalkDir::new(src_dir).follow_links(false) {
|
||
let entry = entry?;
|
||
let path = entry.path();
|
||
let rel_path = path.strip_prefix(src_dir).unwrap();
|
||
let zip_path = dest_dir.join(rel_path);
|
||
|
||
if entry.file_type().is_dir() {
|
||
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
|
||
continue;
|
||
}
|
||
|
||
// Process regular files and symlinks, skip other special files
|
||
if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
|
||
continue;
|
||
}
|
||
|
||
// Get file permissions to preserve executable bits (Unix only)
|
||
let file_opts = {
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let metadata = entry.metadata()?;
|
||
let permissions = metadata.permissions();
|
||
let mode = permissions.mode();
|
||
opts.unix_permissions(mode)
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
opts
|
||
}
|
||
};
|
||
|
||
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
|
||
|
||
if entry.file_type().is_symlink() {
|
||
// For symlinks, read the target and store it as file content
|
||
// This won't create actual symlinks but avoids infinite loops
|
||
if let Ok(target) = fs::read_link(path) {
|
||
let target_str = target.to_string_lossy();
|
||
zip.write_all(target_str.as_bytes())?;
|
||
}
|
||
} else {
|
||
// For regular files, read the content
|
||
let data = fs::read(path).context("Failed to read file while zipping")?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Add directory to zip without following symlinks and skipping parent directory creation
|
||
fn add_dir_to_zip_no_follow_skip_parents<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
src_dir: &Path,
|
||
dest_dir: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
for entry in walkdir::WalkDir::new(src_dir).follow_links(false) {
|
||
let entry = entry?;
|
||
let path = entry.path();
|
||
let rel_path = path.strip_prefix(src_dir).unwrap();
|
||
let zip_path = dest_dir.join(rel_path);
|
||
|
||
if entry.file_type().is_dir() {
|
||
// Only create subdirectories within the package, not the main app/node_modules path
|
||
if !rel_path.as_os_str().is_empty() {
|
||
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Process regular files and symlinks, skip other special files
|
||
if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
|
||
continue;
|
||
}
|
||
|
||
// Get file permissions to preserve executable bits (Unix only)
|
||
let file_opts = {
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let metadata = entry.metadata()?;
|
||
let permissions = metadata.permissions();
|
||
let mode = permissions.mode();
|
||
opts.unix_permissions(mode)
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
opts
|
||
}
|
||
};
|
||
|
||
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
|
||
|
||
if entry.file_type().is_symlink() {
|
||
// For symlinks, read the target and store it as file content
|
||
// This won't create actual symlinks but avoids infinite loops
|
||
if let Ok(target) = fs::read_link(path) {
|
||
let target_str = target.to_string_lossy();
|
||
zip.write_all(target_str.as_bytes())?;
|
||
}
|
||
} else {
|
||
// For regular files, read the content
|
||
let data = fs::read(path).context("Failed to read file while zipping")?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Add directory to zip, excluding node_modules from the source directory
|
||
fn add_dir_to_zip_excluding_node_modules<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
src_dir: &Path,
|
||
dest_dir: &Path,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
for entry in walkdir::WalkDir::new(src_dir).follow_links(true) {
|
||
let entry = entry?;
|
||
let path = entry.path();
|
||
let rel_path = path.strip_prefix(src_dir).unwrap();
|
||
let zip_path = dest_dir.join(rel_path);
|
||
|
||
// Exclude node_modules from the source directory
|
||
if rel_path.starts_with("node_modules") {
|
||
continue;
|
||
}
|
||
|
||
if entry.file_type().is_dir() {
|
||
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
|
||
continue;
|
||
}
|
||
|
||
// Process regular files and symlinks, skip other special files
|
||
if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
|
||
continue;
|
||
}
|
||
|
||
// Get file permissions to preserve executable bits (Unix only)
|
||
let file_opts = {
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let metadata = fs::metadata(path)?;
|
||
let permissions = metadata.permissions();
|
||
let mode = permissions.mode();
|
||
opts.unix_permissions(mode)
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
opts
|
||
}
|
||
};
|
||
|
||
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
|
||
let data = fs::read(path).context("Failed to read file while zipping")?;
|
||
zip.write_all(&data)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Copy a package from workspace node_modules (for regular npm/yarn workspaces)
|
||
fn copy_workspace_package<W>(
|
||
zip: &mut ZipWriter<W>,
|
||
node_modules_path: &Path,
|
||
package_name: &str,
|
||
opts: zip::write::FileOptions<'static, ()>,
|
||
) -> Result<()>
|
||
where
|
||
W: Write + Read + std::io::Seek,
|
||
{
|
||
let dest_path = Path::new("app/node_modules").join(package_name);
|
||
let package_path = node_modules_path.join(package_name);
|
||
|
||
if package_path.exists() {
|
||
let target_path = if package_path.is_symlink() {
|
||
// Follow the symlink
|
||
let target = fs::read_link(&package_path)?;
|
||
if target.is_absolute() {
|
||
target
|
||
} else {
|
||
package_path.parent().unwrap().join(target).canonicalize()?
|
||
}
|
||
} else {
|
||
package_path
|
||
};
|
||
|
||
if target_path.exists() {
|
||
add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts)?;
|
||
return Ok(());
|
||
}
|
||
}
|
||
|
||
anyhow::bail!("Package {} not found in workspace node_modules", package_name)
|
||
}
|
||
|
||
/// Resolve dependencies for regular workspaces (non-pnpm)
|
||
fn resolve_workspace_dependencies(
|
||
node_modules_path: &Path,
|
||
package_name: &str,
|
||
resolved: &mut std::collections::HashSet<String>,
|
||
depth: usize,
|
||
) -> Result<()> {
|
||
// Avoid infinite recursion
|
||
if depth > 20 {
|
||
return Ok(());
|
||
}
|
||
|
||
// If already resolved, skip
|
||
if resolved.contains(package_name) {
|
||
return Ok(());
|
||
}
|
||
|
||
resolved.insert(package_name.to_string());
|
||
|
||
// Try to find the package and read its package.json
|
||
let package_path = node_modules_path.join(package_name);
|
||
let package_json_path = if package_path.is_symlink() {
|
||
let target = fs::read_link(&package_path)?;
|
||
let target_path = if target.is_absolute() {
|
||
target
|
||
} else {
|
||
package_path.parent().unwrap().join(target).canonicalize()?
|
||
};
|
||
target_path.join("package.json")
|
||
} else {
|
||
package_path.join("package.json")
|
||
};
|
||
|
||
if !package_json_path.exists() {
|
||
return Ok(()); // Skip packages we can't find
|
||
}
|
||
|
||
let package_json_content = fs::read_to_string(&package_json_path)
|
||
.context("Failed to read package.json")?;
|
||
|
||
if let Ok(package_json) = serde_json::from_str::<Value>(&package_json_content) {
|
||
// Add production dependencies
|
||
if let Some(deps) = package_json["dependencies"].as_object() {
|
||
for dep_name in deps.keys() {
|
||
resolve_workspace_dependencies(node_modules_path, dep_name, resolved, depth + 1)?;
|
||
}
|
||
}
|
||
|
||
// Also include peerDependencies that are actually installed
|
||
if let Some(peer_deps) = package_json["peerDependencies"].as_object() {
|
||
for dep_name in peer_deps.keys() {
|
||
let dep_path = node_modules_path.join(dep_name);
|
||
if dep_path.exists() {
|
||
resolve_workspace_dependencies(node_modules_path, dep_name, resolved, depth + 1)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also include optionalDependencies that are actually installed
|
||
if let Some(optional_deps) = package_json["optionalDependencies"].as_object() {
|
||
for dep_name in optional_deps.keys() {
|
||
let dep_path = node_modules_path.join(dep_name);
|
||
if dep_path.exists() {
|
||
resolve_workspace_dependencies(node_modules_path, dep_name, resolved, depth + 1)?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
|