diff --git a/.github/workflows/pm-ci.yml b/.github/workflows/pm-ci.yml index 4a17dadfd..dd736efc1 100644 --- a/.github/workflows/pm-ci.yml +++ b/.github/workflows/pm-ci.yml @@ -109,7 +109,6 @@ jobs: - name: Install dependencies run: utoo install - continue-on-error: true - name: Setup node x86 uses: actions/setup-node@v4 diff --git a/Cargo.lock b/Cargo.lock index 6bd7a48ba..988bebfd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10870,6 +10870,7 @@ dependencies = [ "reqwest 0.12.24", "serde", "serde_json", + "serde_yaml", "sha1", "sha2", "tar", diff --git a/Cargo.toml b/Cargo.toml index cf1ff95c9..8be6c5333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ tempfile = "3" thiserror = "1.0" tokio = { version = "1.47.1" } tokio-fs-ext = "0.7.8" +serde_yaml = "0.9" toml = "0.8" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] } diff --git a/agents/rust-code-guard.md b/agents/rust-code-guard.md index f3386f2d4..b4320d882 100644 --- a/agents/rust-code-guard.md +++ b/agents/rust-code-guard.md @@ -367,3 +367,4 @@ Scan through this list during every review for high-frequency Rust anti-patterns | A16 | Broad Re-export Leak | `pub use module::*` leaking internal helpers | Export precise types explicitly | | A17 | Large Enum Variant Size | A single large variant inflating the enum footprint | Heap-allocate the large variant via `Box` | | A18 | Trivial Wrapper Function | One-line fn that just forwards to another fn with identical signature | Call the underlying function directly | +| A19 | Repetitive Conditional Push | Repeated `if x > 0 { vec.push(format!(...)) }` blocks with same structure | Data-drive with `[(value, label)].filter().map().collect()` | diff --git a/crates/pm/Cargo.toml b/crates/pm/Cargo.toml index 4a40baf76..a2f27d9ba 100644 --- a/crates/pm/Cargo.toml +++ b/crates/pm/Cargo.toml @@ -47,6 +47,7 @@ reqwest = { version = "0.12", default-features = false, features = [ ] } serde = { workspace = true } serde_json = { version = "1.0", features = ["preserve_order"] } +serde_yaml = { workspace = true } sha1 = { workspace = true } sha2 = { workspace = true } tar = "0.4" diff --git a/crates/pm/src/helper/migrate.rs b/crates/pm/src/helper/migrate.rs new file mode 100644 index 000000000..dfd1797c6 --- /dev/null +++ b/crates/pm/src/helper/migrate.rs @@ -0,0 +1,486 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::util::config_file::Config; +use crate::util::format_print::print_migrate_result; +use crate::util::json::load_package_json; + +#[derive(Debug)] +pub struct MigrateResult { + pub fields: Vec<(String, usize)>, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum FromPm { + #[default] + None, + Pnpm, +} + +/// Field mapping from pnpm-workspace.yaml to package.json. +/// (pnpm_key, package_json_key) +const PNPM_PKG_FIELD_MAP: &[(&str, &str)] = + &[("packages", "workspaces"), ("overrides", "overrides")]; + +/// TOML keys that pnpm migration is allowed to write into .utoo.toml. +/// Only these sections are merged; all other existing config is preserved. +const PNPM_MIGRATE_KEYS: &[&str] = &["catalog", "catalogs"]; + +/// Fields we care about from pnpm-workspace.yaml. +/// Missing fields become `None`; present fields (even empty) become `Some`. +#[derive(Debug, Deserialize, Serialize)] +struct PnpmWorkspace { + #[serde(default, skip_serializing_if = "Option::is_none")] + packages: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + catalog: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + catalogs: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + overrides: Option>, +} + +/// Migrate a pnpm project to utoo config files. +/// +/// Reads `pnpm-workspace.yaml` and: +/// 1. Updates `package.json` with `workspaces` and `overrides` +/// 2. Writes `.utoo.toml` with catalog configuration +/// +/// Must run before `Config::load` is first called so that `MERGED_CONFIG` +/// picks up the generated `.utoo.toml`. +pub async fn migrate_from_pnpm(root_path: &Path) -> Result<()> { + let yaml_path = root_path.join("pnpm-workspace.yaml"); + let yaml_content = match crate::fs::read_to_string(&yaml_path).await { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!("pnpm-workspace.yaml not found in {}", root_path.display()); + } + Err(e) => return Err(anyhow::Error::from(e).context("Failed to read pnpm-workspace.yaml")), + }; + + let workspace: PnpmWorkspace = + serde_yaml::from_str(&yaml_content).context("Failed to parse pnpm-workspace.yaml")?; + + // 1. Update package.json with mapped fields + let ws_value = serde_json::to_value(&workspace)?; + + let pkg_updates: Vec<_> = PNPM_PKG_FIELD_MAP + .iter() + .filter_map(|(from, to)| ws_value.get(*from).map(|v| (*to, v.clone()))) + .collect(); + + // Collect field counts from package.json updates + let mut fields: Vec<(String, usize)> = pkg_updates + .iter() + .map(|(key, v)| { + let len = v + .as_array() + .map_or_else(|| v.as_object().map_or(0, |o| o.len()), |a| a.len()); + (key.to_string(), len) + }) + .filter(|(_, len)| *len > 0) + .collect(); + + if !pkg_updates.is_empty() { + let mut pkg: serde_json::Value = load_package_json(root_path).await?; + let pkg_obj = pkg + .as_object_mut() + .context("package.json is not an object")?; + for (key, value) in pkg_updates { + pkg_obj.insert(key.to_string(), value); + } + crate::fs::write( + root_path.join("package.json"), + serde_json::to_string_pretty(&pkg)? + "\n", + ) + .await + .context("Failed to write package.json")?; + } + + // 2. Merge whitelisted keys into existing .utoo.toml + let toml_updates: Vec<_> = PNPM_MIGRATE_KEYS + .iter() + .filter_map(|key| ws_value.get(*key).map(|v| (*key, v.clone()))) + .collect(); + + if !toml_updates.is_empty() { + let toml_path = root_path.join(".utoo.toml"); + let existing = Config::load_from_path(&toml_path).await?; + let mut base = toml::Table::try_from(existing)?; + let incoming: toml::Table = toml::from_str(&toml::to_string(&workspace)?)?; + + for key in PNPM_MIGRATE_KEYS { + if let Some(value) = incoming.get(*key) { + base.insert(key.to_string(), value.clone()); + } + } + + crate::fs::write(&toml_path, toml::to_string_pretty(&base)?) + .await + .context("Failed to write .utoo.toml")?; + + // Count catalog entries + let catalogs_count: usize = toml_updates + .iter() + .map(|(_, v)| { + v.as_object().map_or(0, |o| { + // nested catalogs: sum inner map sizes; flat catalog: count keys + o.values() + .map(|iv| iv.as_object().map_or(1, |io| io.len())) + .sum() + }) + }) + .sum(); + fields.push(("catalogs".to_string(), catalogs_count)); + } + + print_migrate_result(&MigrateResult { fields })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::config_file::Config; + + #[test] + fn test_parse_pnpm_workspace_yaml() { + let yaml = r#" +packages: + - packages/* + - plugins/* + +catalog: + lodash: ^4.17.21 + typescript: ^5.0.0 + +catalogs: + legacy: + debug: ^3.2.7 + +overrides: + vite: npm:rolldown-vite@^7.1.13 +"#; + let workspace: PnpmWorkspace = serde_yaml::from_str(yaml).unwrap(); + let packages = workspace.packages.unwrap(); + assert_eq!(packages, vec!["packages/*", "plugins/*"]); + let catalog = workspace.catalog.unwrap(); + assert_eq!(catalog.get("lodash").unwrap(), "^4.17.21"); + let catalogs = workspace.catalogs.unwrap(); + assert_eq!( + catalogs.get("legacy").unwrap().get("debug").unwrap(), + "^3.2.7" + ); + let overrides = workspace.overrides.unwrap(); + assert_eq!(overrides.get("vite").unwrap(), "npm:rolldown-vite@^7.1.13"); + } + + #[test] + fn test_parse_empty_pnpm_workspace() { + let yaml = "packages:\n - packages/*\n"; + let workspace: PnpmWorkspace = serde_yaml::from_str(yaml).unwrap(); + assert!(workspace.packages.is_some()); + assert!(workspace.catalog.is_none()); + assert!(workspace.catalogs.is_none()); + assert!(workspace.overrides.is_none()); + } + + #[tokio::test] + async fn test_migrate_missing_yaml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let result = migrate_from_pnpm(dir.path()).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("pnpm-workspace.yaml not found") + ); + } + + #[tokio::test] + async fn test_migrate_updates_package_json() { + let dir = tempfile::tempdir().unwrap(); + + let pkg = serde_json::json!({ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "lodash": "catalog:" + } + }); + std::fs::write( + dir.path().join("package.json"), + serde_json::to_string_pretty(&pkg).unwrap(), + ) + .unwrap(); + + std::fs::write( + dir.path().join("pnpm-workspace.yaml"), + "packages:\n - packages/*\n - apps/*\noverrides:\n vite: npm:rolldown-vite@^7\n", + ) + .unwrap(); + + migrate_from_pnpm(dir.path()).await.unwrap(); + + let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + let ws = pkg["workspaces"].as_array().unwrap(); + assert_eq!(ws.len(), 2); + assert_eq!(ws[0], "packages/*"); + assert_eq!(ws[1], "apps/*"); + assert_eq!(pkg["overrides"]["vite"], "npm:rolldown-vite@^7"); + assert_eq!(pkg["name"], "test-project"); + assert_eq!(pkg["dependencies"]["lodash"], "catalog:"); + } + + #[tokio::test] + async fn test_migrate_writes_utoo_toml() { + let dir = tempfile::tempdir().unwrap(); + + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + + std::fs::write( + dir.path().join("pnpm-workspace.yaml"), + "packages: []\ncatalog:\n lodash: ^4.17.21\n debug: ^4.3.4\ncatalogs:\n legacy:\n debug: ^3.2.7\n", + ) + .unwrap(); + + migrate_from_pnpm(dir.path()).await.unwrap(); + + let toml_content = std::fs::read_to_string(dir.path().join(".utoo.toml")).unwrap(); + assert!(toml_content.contains("lodash")); + assert!(toml_content.contains("debug")); + assert!(toml_content.contains("[catalogs.legacy]")); + } + + #[test] + fn test_parse_no_packages_field() { + let yaml = r#" +catalog: + react: ^18.0.0 +"#; + let workspace: PnpmWorkspace = serde_yaml::from_str(yaml).unwrap(); + assert!(workspace.packages.is_none()); + let catalog = workspace.catalog.unwrap(); + assert_eq!(catalog.get("react").unwrap(), "^18.0.0"); + } + + #[test] + fn test_parse_scoped_packages_in_catalog() { + let yaml = r#" +catalog: + '@types/node': ^20.0.0 + '@eggjs/core': ^3.0.0 + lodash: ^4.17.21 +"#; + let workspace: PnpmWorkspace = serde_yaml::from_str(yaml).unwrap(); + let catalog = workspace.catalog.unwrap(); + assert_eq!(catalog.get("@types/node").unwrap(), "^20.0.0"); + assert_eq!(catalog.get("@eggjs/core").unwrap(), "^3.0.0"); + assert_eq!(catalog.get("lodash").unwrap(), "^4.17.21"); + } + + #[test] + fn test_parse_npm_alias_in_overrides() { + let yaml = r#" +overrides: + vite: npm:rolldown-vite@^7.1.13 + typescript: ^5.0.0 +"#; + let workspace: PnpmWorkspace = serde_yaml::from_str(yaml).unwrap(); + let overrides = workspace.overrides.unwrap(); + assert_eq!(overrides.get("vite").unwrap(), "npm:rolldown-vite@^7.1.13"); + assert_eq!(overrides.get("typescript").unwrap(), "^5.0.0"); + } + + #[test] + fn test_parse_multiple_named_catalogs() { + let yaml = r#" +catalogs: + react17: + react: ^17.0.0 + react-dom: ^17.0.0 + react18: + react: ^18.0.0 + react-dom: ^18.0.0 + legacy: + debug: ^3.2.7 +"#; + let workspace: PnpmWorkspace = serde_yaml::from_str(yaml).unwrap(); + let catalogs = workspace.catalogs.unwrap(); + assert_eq!(catalogs.len(), 3); + assert_eq!(catalogs["react17"].get("react").unwrap(), "^17.0.0"); + assert_eq!(catalogs["react18"].get("react").unwrap(), "^18.0.0"); + assert_eq!(catalogs["legacy"].get("debug").unwrap(), "^3.2.7"); + } + + #[test] + fn test_parse_ignores_unknown_fields() { + let yaml = r#" +packages: + - packages/* +catalogMode: prefer +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - '@eggjs/*' + - vitest +catalog: + lodash: ^4.17.21 +"#; + let workspace: PnpmWorkspace = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(workspace.packages.unwrap(), vec!["packages/*"]); + assert_eq!( + workspace.catalog.unwrap().get("lodash").unwrap(), + "^4.17.21" + ); + } + + #[tokio::test] + async fn test_migrate_no_workspaces_only_catalogs() { + let dir = tempfile::tempdir().unwrap(); + + std::fs::write( + dir.path().join("package.json"), + r#"{"name": "my-app", "dependencies": {"lodash": "catalog:"}}"#, + ) + .unwrap(); + + std::fs::write( + dir.path().join("pnpm-workspace.yaml"), + "catalog:\n lodash: ^4.17.21\n", + ) + .unwrap(); + + migrate_from_pnpm(dir.path()).await.unwrap(); + + let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(pkg.get("workspaces").is_none()); + assert_eq!(pkg["name"], "my-app"); + assert_eq!(pkg["dependencies"]["lodash"], "catalog:"); + + let toml_content = std::fs::read_to_string(dir.path().join(".utoo.toml")).unwrap(); + assert!(toml_content.contains("lodash")); + } + + #[tokio::test] + async fn test_migrate_no_catalogs_only_workspaces() { + let dir = tempfile::tempdir().unwrap(); + + std::fs::write(dir.path().join("package.json"), r#"{"name": "mono"}"#).unwrap(); + + std::fs::write( + dir.path().join("pnpm-workspace.yaml"), + "packages:\n - packages/*\n - apps/*\n", + ) + .unwrap(); + + migrate_from_pnpm(dir.path()).await.unwrap(); + + let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + let ws = pkg["workspaces"].as_array().unwrap(); + assert_eq!(ws.len(), 2); + + assert!(!dir.path().join(".utoo.toml").exists()); + } + + #[tokio::test] + async fn test_migrate_preserves_existing_package_json_fields() { + let dir = tempfile::tempdir().unwrap(); + + let pkg = serde_json::json!({ + "name": "@eggjs/monorepo", + "version": "4.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsdown", + "test": "vitest" + }, + "devDependencies": { + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=18" + } + }); + std::fs::write( + dir.path().join("package.json"), + serde_json::to_string_pretty(&pkg).unwrap(), + ) + .unwrap(); + + std::fs::write( + dir.path().join("pnpm-workspace.yaml"), + "packages:\n - packages/*\noverrides:\n vite: npm:rolldown-vite@^7\n", + ) + .unwrap(); + + migrate_from_pnpm(dir.path()).await.unwrap(); + + let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + + assert!(pkg["workspaces"].is_array()); + assert_eq!(pkg["overrides"]["vite"], "npm:rolldown-vite@^7"); + + assert_eq!(pkg["name"], "@eggjs/monorepo"); + assert_eq!(pkg["version"], "4.0.0"); + assert_eq!(pkg["private"], true); + assert_eq!(pkg["type"], "module"); + assert_eq!(pkg["scripts"]["build"], "tsdown"); + assert_eq!(pkg["devDependencies"]["typescript"], "catalog:"); + assert_eq!(pkg["engines"]["node"], ">=18"); + } + + #[tokio::test] + async fn test_migrate_preserves_existing_utoo_toml_config() { + let dir = tempfile::tempdir().unwrap(); + + std::fs::write(dir.path().join("package.json"), r#"{"name": "my-app"}"#).unwrap(); + + // Pre-existing .utoo.toml with registry config + std::fs::write( + dir.path().join(".utoo.toml"), + "[values]\nregistry = \"https://registry.npmmirror.com\"\n", + ) + .unwrap(); + + std::fs::write( + dir.path().join("pnpm-workspace.yaml"), + "catalog:\n lodash: ^4.17.21\ncatalogs:\n legacy:\n debug: ^3.2.7\n", + ) + .unwrap(); + + migrate_from_pnpm(dir.path()).await.unwrap(); + + let config: Config = + toml::from_str(&std::fs::read_to_string(dir.path().join(".utoo.toml")).unwrap()) + .unwrap(); + + // Existing config preserved + assert_eq!( + config.get("registry").unwrap(), + Some("https://registry.npmmirror.com".to_string()) + ); + + // New catalogs added + let catalogs = config.catalogs(); + assert_eq!( + catalogs.get("").unwrap().get("lodash"), + Some(&"^4.17.21".to_string()) + ); + assert_eq!( + catalogs.get("legacy").unwrap().get("debug"), + Some(&"^3.2.7".to_string()) + ); + } +} diff --git a/crates/pm/src/helper/mod.rs b/crates/pm/src/helper/mod.rs index cc466b7e7..17451c93e 100644 --- a/crates/pm/src/helper/mod.rs +++ b/crates/pm/src/helper/mod.rs @@ -5,6 +5,7 @@ pub mod git; pub mod global_bin; pub mod install_runtime; pub mod lock; +pub mod migrate; pub mod ruborist_context; pub mod tree_builder; pub mod workspace; diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 7fcf231b4..be7194c22 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -44,6 +44,8 @@ use crate::constants::cmd::{ use crate::constants::{APP_ABOUT, APP_NAME, APP_VERSION}; use crate::helper::workspace::init_project_root; +use crate::helper::migrate::{FromPm, migrate_from_pnpm}; + fn detect_shell_from_env() -> Option { // Most common on Unix-like systems. let shell_path = std::env::var("SHELL").ok()?; @@ -176,6 +178,10 @@ enum Commands { /// Dependency types to omit #[arg(long, value_delimiter = ',')] omit: Vec, + + /// Migrate from another package manager before installing + #[arg(long)] + from: Option, }, /// Uninstall dependencies #[command(name = UNINSTALL_NAME, alias = UNINSTALL_ALIAS, about = UNINSTALL_ABOUT)] @@ -399,6 +405,17 @@ async fn async_main() -> Result<()> { "Logger initialized" ); + // Run --from migration early, before config is cached by init_registry + if let Some(Commands::Install { + from: Some(FromPm::Pnpm), + .. + }) = &cli.command + { + let cwd = std::env::current_dir()?; + let root_path = init_project_root(&cwd).await?; + migrate_from_pnpm(&root_path).await?; + } + // global registry init_registry(cli.registry).await; @@ -437,6 +454,7 @@ async fn async_main() -> Result<()> { prefix, production, omit, + from: _, }) => { // Build omit config: production = omit dev + optional let mut omit_set: std::collections::HashSet = omit.into_iter().collect(); diff --git a/crates/pm/src/util/format_print.rs b/crates/pm/src/util/format_print.rs index 77d22d1bc..4efdf7b24 100644 --- a/crates/pm/src/util/format_print.rs +++ b/crates/pm/src/util/format_print.rs @@ -1,9 +1,36 @@ -use colored::Colorize; +use std::fmt; use std::io; +use std::io::Write; + +use colored::Colorize; use term_size; +use crate::helper::migrate::MigrateResult; use crate::service::pm_pack::PackResult; +impl fmt::Display for MigrateResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let parts: Vec = self + .fields + .iter() + .filter(|(_, count)| *count > 0) + .map(|(label, count)| format!("{count} {label}")) + .collect(); + + write!( + f, + "{} pnpm {} {}", + "✓".green(), + "→".dimmed(), + parts.join(", ") + ) + } +} + +pub fn print_migrate_result(result: &MigrateResult) -> io::Result<()> { + writeln!(io::stdout(), "{result}") +} + /// Write pack file listing and summary metadata to the given writer. /// /// Fields with empty/zero values (e.g. dry-run pack with no tarball) are omitted. diff --git a/e2e/utoo-pm.sh b/e2e/utoo-pm.sh index cb3a029d2..b9224a8d6 100755 --- a/e2e/utoo-pm.sh +++ b/e2e/utoo-pm.sh @@ -494,6 +494,44 @@ fi echo -e "${GREEN}PASS: workspace devDep cycle handled correctly${NC}" cd ../../.. +# Case: pnpm migration (eggjs/egg) +echo -e "${YELLOW}Case: pnpm migration (eggjs/egg)${NC}" +EGG_DIR=$(mktemp -d) +git clone --branch next --single-branch --depth 1 https://github.com/eggjs/egg.git "$EGG_DIR" +pushd "$EGG_DIR" + +utoo install --from pnpm --ignore-scripts --registry=https://registry.npmjs.org || { echo -e "${RED}FAIL: utoo install --from pnpm failed for eggjs/egg${NC}"; exit 1; } + +# Verify workspaces field was added to package.json +node -e " + const pkg = require('./package.json'); + const ws = pkg.workspaces; + if (!ws || !Array.isArray(ws)) throw new Error('workspaces not set'); + if (!ws.includes('packages/*')) throw new Error('missing packages/*'); + console.log(' workspaces:', ws.length, 'patterns'); +" + +# Verify overrides were added +node -e " + const pkg = require('./package.json'); + if (!pkg.overrides) throw new Error('overrides not set'); + if (!pkg.overrides.vite) throw new Error('vite override missing'); + console.log(' overrides:', Object.keys(pkg.overrides).length, 'entries'); +" + +# Verify .utoo.toml was created with catalogs +[ -f ".utoo.toml" ] || { echo -e "${RED}FAIL: .utoo.toml not created${NC}"; exit 1; } +grep -q 'lodash' .utoo.toml || { echo -e "${RED}FAIL: catalog missing lodash${NC}"; exit 1; } +grep -q 'path-to-regexp' .utoo.toml || { echo -e "${RED}FAIL: named catalog missing${NC}"; exit 1; } + +# Verify node_modules was created (install ran successfully) +[ -d "node_modules" ] || { echo -e "${RED}FAIL: node_modules not created${NC}"; exit 1; } + +echo -e "${GREEN}PASS: pnpm migration (eggjs/egg)${NC}" + +popd +rm -rf "$EGG_DIR" + # Case: install-node + esbuild postinstall echo -e "${YELLOW}Case: install-node + esbuild${NC}" ESBUILD_DIR=$(mktemp -d)