diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1601369b37..8307ac84f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,10 +17,10 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo install cargo-binstall ``` -Initial setup to install dependencies for Vite+: +Initial setup to prepare the repo for local development: ``` -just init +pnpm install:dev ``` ### Windows @@ -37,10 +37,10 @@ Install Rust & Cargo from [rustup.rs](https://rustup.rs/), then install `cargo-b cargo install cargo-binstall ``` -Initial setup to install dependencies for Vite+: +Initial setup to prepare the repo for local development: ```powershell -just init +pnpm install:dev ``` **Note:** Run commands in PowerShell or Windows Terminal. Some commands may require elevated permissions. @@ -53,6 +53,23 @@ To create a release build of Vite+ and all upstream dependencies, run: just build ``` +## Local CLI workflow + +``` +pnpm bootstrap:dev +pnpm test +``` + +This prepares the local `rolldown/` and `vite/` checkouts, installs dependencies, builds the repo-local CLI artifacts, and runs tests without reading `~/.vite-plus`. + +If you only want to prepare the repo after cloning it, run: + +``` +pnpm install:dev +``` + +If you prefer the existing Just-based setup, `just init` now delegates to the same repo-local install flow. + ## Install the Vite+ Global CLI from source code ``` @@ -60,14 +77,14 @@ pnpm bootstrap-cli vp --version ``` -This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus`. +Use this only when you specifically want to validate the install flow or the globally installed CLI. ## Workflow for build and test You can run this command to build, test and check if there are any snapshot changes: ``` -pnpm bootstrap-cli && pnpm test && git status +pnpm build:cli && pnpm test && git status ``` ## Running Snap Tests @@ -87,6 +104,8 @@ pnpm -F vite-plus snap-test-global pnpm -F vite-plus snap-test-global ``` +Global CLI snap tests use the repo-local debug binary and `packages/cli/dist`; they do not require `~/.vite-plus/bin`. + Snap tests auto-generate `snap.txt` files. Check `git diff` to verify output changes are correct. ## Verified Commits diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index b28bdc39e2..ec48d09bfa 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -26,7 +26,7 @@ use crate::{ }, package_metadata::PackageMetadata, }, - global::{CORE_SHIMS, parse_package_spec}, + global::CORE_SHIMS, }, error::Error, }; @@ -364,8 +364,14 @@ async fn install_one( // 4. Run npm install with prefix set to staging directory // Pipe stdout/stderr so npm output is hidden on success, shown on failure + let mut install_args = vec!["install", "-g", "--no-fund"]; + if is_local_package_spec(package_spec) { + install_args.push("--install-links"); + } + install_args.push(package_spec); + let output = Command::new(npm_path.as_path()) - .args(["install", "-g", "--no-fund", &package_spec]) + .args(install_args) .env("npm_config_prefix", staging_dir.as_path()) .env("PATH", format_path_prepended(node_bin_dir.as_path())) .stdout(Stdio::piped()) @@ -448,6 +454,138 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { Ok(()) } +/// Resolve the version currently published for a package spec. +/// +/// `package_spec` may be a bare package name (`typescript`) or include a +/// version/tag (`typescript@beta`, `@scope/pkg@1.0.0`). The command returns the +/// version that npm resolves for that spec. +#[expect(dead_code)] +pub(crate) async fn latest_package_version(package_spec: &str) -> Result { + // Resolve from current directory + let node_version = { + let cwd = match current_dir() { + Ok(cwd) => cwd, + Err(error) => { + let error = + Error::ConfigError(format!("Cannot get current directory: {}", error).into()); + return Err(error); + } + }; + let resolution = match resolve_version(&cwd).await { + Ok(resolution) => resolution, + Err(error) => return Err(error), + }; + resolution.version + }; + + // Ensure Node.js is installed + let runtime = match vite_js_runtime::download_runtime( + vite_js_runtime::JsRuntimeType::Node, + &node_version, + ) + .await + { + Ok(runtime) => runtime, + Err(error) => { + let error = Error::RuntimeDownload(error); + return Err(error); + } + }; + + let node_bin_dir = runtime.get_bin_prefix(); + let npm_path = + if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; + + let output = Command::new(npm_path.as_path()) + .args(["view", package_spec, "version", "--json"]) + .env("PATH", format_path_prepended(node_bin_dir.as_path())) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(Error::ConfigError( + format!("npm view failed for {package_spec}: {stderr}").into(), + )); + } + + parse_npm_view_version(&output.stdout) +} + +#[expect(dead_code)] +fn parse_npm_view_version(stdout: &[u8]) -> Result { + let raw = String::from_utf8_lossy(stdout); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(Error::ConfigError("npm view returned an empty version".into())); + } + + match serde_json::from_str::(trimmed) { + Ok(serde_json::Value::String(version)) => Ok(version), + Ok(serde_json::Value::Array(versions)) => versions + .iter() + .rev() + .find_map(|version| version.as_str()) + .map(str::to_string) + .ok_or_else(|| Error::ConfigError("npm view returned an empty version list".into())), + _ => Ok(trimmed.to_string()), + } +} + +/// Return true for package specs that refer to local filesystem content. +pub(crate) fn is_local_package_spec(spec: &str) -> bool { + spec == "." + || spec == ".." + || spec.starts_with("./") + || spec.starts_with("../") + || spec.starts_with(".\\") + || spec.starts_with("..\\") + || spec.starts_with('/') + || spec.starts_with("file:") + || (cfg!(windows) + && spec.len() >= 3 + && spec.as_bytes()[1] == b':' + && (spec.as_bytes()[2] == b'\\' || spec.as_bytes()[2] == b'/')) +} + +/// Parse package spec into name and optional version. +pub(crate) fn parse_package_spec(spec: &str) -> (String, Option) { + if is_local_package_spec(spec) { + return (resolve_local_package_name(spec).unwrap_or_else(|| spec.to_string()), None); + } + + // Handle scoped packages: @scope/name@version + if spec.starts_with('@') { + // Find the second @ for version + if let Some(idx) = spec[1..].find('@') { + let idx = idx + 1; // Adjust for the skipped first char + return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); + } + return (spec.to_string(), None); + } + + // Handle regular packages: name@version + if let Some(idx) = spec.find('@') { + return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); + } + + (spec.to_string(), None) +} + +fn resolve_local_package_name(spec: &str) -> Option { + let spec = spec.strip_prefix("file:").unwrap_or(spec); + let package_dir = if let Some(package_dir) = AbsolutePathBuf::new(spec.into()) { + package_dir + } else { + current_dir().ok()?.join(spec) + }; + let package_json = std::fs::read_to_string(package_dir.join("package.json").as_path()).ok()?; + let json: serde_json::Value = serde_json::from_str(&package_json).ok()?; + json.get("name").and_then(|value| value.as_str()).map(str::to_string) +} + /// Binary info extracted from package.json. struct BinaryInfo { /// Binary name (the command users will run) @@ -631,6 +769,22 @@ mod tests { use super::*; use crate::commands::global::is_local_package_spec; + struct CurrentDirGuard(std::path::PathBuf); + + impl CurrentDirGuard { + fn change_to(path: &std::path::Path) -> Self { + let previous = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self(previous) + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.0).unwrap(); + } + } + /// RAII guard that sets `VP_TRAMPOLINE_PATH` to a fake binary on creation /// and clears it on drop. Ensures cleanup even on test panics. #[cfg(windows)] @@ -907,6 +1061,27 @@ mod tests { assert_eq!(version, Some("20.0.0".to_string())); } + #[test] + #[serial_test::serial] + fn test_parse_package_spec_local_path_uses_package_name() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let _cwd_guard = CurrentDirGuard::change_to(temp_dir.path()); + let package_dir = temp_dir.path().join("fixture-pkg"); + + std::fs::create_dir_all(&package_dir).unwrap(); + std::fs::write( + package_dir.join("package.json"), + r#"{ "name": "resolved-local-package", "version": "1.0.0" }"#, + ) + .unwrap(); + + let (name, version) = parse_package_spec("./fixture-pkg"); + assert_eq!(name, "resolved-local-package"); + assert_eq!(version, None); + } + #[test] fn test_is_javascript_binary_with_js_extension() { use tempfile::TempDir; diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 0f6a709be7..998dfdac41 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -441,6 +441,17 @@ mod tests { v.iter().map(|s| s.to_string()).collect() } + fn try_parse_args_from_test(args: Vec) -> Result { + let handle = std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || try_parse_args_from(args)) + .expect("Expected parser test thread to spawn"); + match handle.join() { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), + } + } + #[test] fn normalize_args_rewrites_vp_node_to_env_exec_node() { let input = s(&["vp", "node", "script.js", "foo", "--flag"]); @@ -485,8 +496,8 @@ mod tests { #[test] fn unknown_argument_detected_without_pass_as_value_hint() { - let error = try_parse_args_from(["vp".to_string(), "--cache".to_string()]) - .expect_err("Expected parse error"); + let error = + try_parse_args_from_test(s(&["vp", "--cache"])).expect_err("Expected parse error"); assert_eq!(error.kind(), ErrorKind::UnknownArgument); assert_eq!(extract_unknown_argument(&error).as_deref(), Some("--cache")); assert!(!has_pass_as_value_suggestion(&error)); @@ -494,13 +505,8 @@ mod tests { #[test] fn unknown_argument_detected_with_pass_as_value_hint() { - let error = try_parse_args_from([ - "vp".to_string(), - "remove".to_string(), - "--stream".to_string(), - "foo".to_string(), - ]) - .expect_err("Expected parse error"); + let error = try_parse_args_from_test(s(&["vp", "remove", "--stream", "foo"])) + .expect_err("Expected parse error"); assert_eq!(error.kind(), ErrorKind::UnknownArgument); assert_eq!(extract_unknown_argument(&error).as_deref(), Some("--stream")); assert!(has_pass_as_value_suggestion(&error)); diff --git a/crates/vite_global_cli/src/tips/mod.rs b/crates/vite_global_cli/src/tips/mod.rs index 2583832e41..fcc5fb15a6 100644 --- a/crates/vite_global_cli/src/tips/mod.rs +++ b/crates/vite_global_cli/src/tips/mod.rs @@ -108,7 +108,17 @@ pub fn tip_context_from_command(command: &str) -> TipContext { // Split simulates what the OS does with command line args let args: Vec = command.split_whitespace().map(String::from).collect(); - let (exit_code, clap_error) = match crate::try_parse_args_from(args.iter().cloned()) { + let parse_args = args.clone(); + let handle = std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || crate::try_parse_args_from(parse_args)) + .expect("Expected parser test thread to spawn"); + let parse_result = match handle.join() { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), + }; + + let (exit_code, clap_error) = match parse_result { Ok(_) => (0, None), Err(e) => (e.exit_code(), Some(e)), }; diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index d5fd005279..8f396f4594 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -354,7 +354,14 @@ mod tests { fn parse_args(args: &[&str]) -> crate::cli::Args { let full: Vec = std::iter::once("vp").chain(args.iter().copied()).map(String::from).collect(); - crate::try_parse_args_from(full).unwrap() + let handle = std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || crate::try_parse_args_from(full)) + .expect("Expected parser test thread to spawn"); + match handle.join() { + Ok(result) => result.unwrap(), + Err(payload) => std::panic::resume_unwind(payload), + } } #[test] diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 38d4fb1340..1b8daa175b 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -134,7 +134,7 @@ "varlet": { "repository": "https://github.com/varletjs/varlet.git", "branch": "dev", - "hash": "83f6c6a418ab9319e07d719d86d4fa952f99e266", + "hash": "175f02cc0e4978d211a57b13d9438b1ac44b50d6", "forceFreshMigration": true } } diff --git a/justfile b/justfile index 83eb0ff2d4..bde131a59e 100644 --- a/justfile +++ b/justfile @@ -18,8 +18,7 @@ _clean_dist: init: _clean_dist _fix_symlinks cargo binstall watchexec-cli cargo-insta typos-cli cargo-shear dprint taplo-cli -y - node packages/tools/src/index.ts sync-remote - pnpm install + pnpm install:dev pnpm -C docs install [unix] diff --git a/package.json b/package.json index ff1b3c929a..d21457cff8 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,17 @@ "type": "module", "scripts": { "build": "pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", - "bootstrap-cli": "pnpm build && cargo build -p vite_global_cli -p vite_trampoline --release && pnpm install-global-cli", + "install:dev": "node scripts/setup-local-dev.mjs", + "bootstrap:dev": "pnpm install:dev && pnpm build:cli", + "build:cli": "tool build-local-cli", + "bootstrap-cli": "pnpm install:dev && tool build-local-cli --release-rust && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", "install-global-cli": "tool install-global-cli", "tsgo": "tsgo -b tsconfig.json", "lint": "vp lint --type-aware --type-check --threads 4", - "test": "vp test run && pnpm -r snap-test", + "test": "pnpm build:cli && pnpm -F vite-plus test && pnpm -F vite-plus snap-test", "fmt": "vp fmt", - "test:unit": "vp test run", + "test:unit": "pnpm build:cli && pnpm -F vite-plus test", "docs:dev": "pnpm -C docs dev", "docs:build": "pnpm -C docs build", "docs:update-trusted-stack-stats": "pnpm -C docs update-trusted-stack-stats", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1c93c5e3f4..d6eb421dcf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -333,9 +333,9 @@ "build-native": "oxnode -C dev ./build.ts --skip-ts", "snap-test": "pnpm snap-test-local && pnpm snap-test-global", "snap-test-local": "tool snap-test", - "snap-test-global": "tool snap-test --dir snap-tests-global --bin-dir ~/.vite-plus/bin", + "snap-test-global": "tool snap-test-global-local", "publish-native": "node ./publish-native-addons.ts", - "test": "vitest run" + "test": "tool local-cli test run" }, "dependencies": { "@oxc-project/types": "catalog:", diff --git a/packages/cli/snap-tests-global/command-env-install-conflict/snap.txt b/packages/cli/snap-tests-global/command-env-install-conflict/snap.txt index dd89567f74..200704587c 100644 --- a/packages/cli/snap-tests-global/command-env-install-conflict/snap.txt +++ b/packages/cli/snap-tests-global/command-env-install-conflict/snap.txt @@ -1,7 +1,7 @@ > vp install -g ./conflict-pkg # Install package with conflicting binary name (uses cwd version) info: Installing 1 global package with Node.js -warn: Package './conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping. -✓ Installed ./conflict-pkg +warn: Package 'conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping. +✓ Installed conflict-pkg Bins: conflict-cli, node > vp remove -g conflict-pkg # Cleanup @@ -9,8 +9,8 @@ Uninstalled conflict-pkg > vp install -g --node 20 ./conflict-pkg # Install with specific Node.js version info: Installing 1 global package with Node.js -warn: Package './conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping. -✓ Installed ./conflict-pkg +warn: Package 'conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping. +✓ Installed conflict-pkg Bins: conflict-cli, node > vp remove -g conflict-pkg # Cleanup diff --git a/packages/cli/snap-tests-global/command-env-install-node-version/snap.txt b/packages/cli/snap-tests-global/command-env-install-node-version/snap.txt index 40f1c040a8..a24c129abd 100644 --- a/packages/cli/snap-tests-global/command-env-install-node-version/snap.txt +++ b/packages/cli/snap-tests-global/command-env-install-node-version/snap.txt @@ -1,6 +1,6 @@ > vp install -g --node 22 ./command-env-install-node-version-pkg # Install with Node.js 22 info: Installing 1 global package with Node.js -✓ Installed ./command-env-install-node-version-pkg +✓ Installed command-env-install-node-version-pkg Bins: command-env-install-node-version-pkg-cli > cat $VP_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])" # Verify Node 22 @@ -11,7 +11,7 @@ Uninstalled command-env-install-node-version-pkg > vp install -g --node 20 ./command-env-install-node-version-pkg # Install with Node.js 20 info: Installing 1 global package with Node.js -✓ Installed ./command-env-install-node-version-pkg +✓ Installed command-env-install-node-version-pkg Bins: command-env-install-node-version-pkg-cli > cat $VP_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])" # Verify Node 20 diff --git a/packages/cli/snap-tests-global/command-env-install-parallel/parallel-pkg-a/cli.js b/packages/cli/snap-tests-global/command-env-install-parallel/parallel-pkg-a/cli.js old mode 100644 new mode 100755 diff --git a/packages/cli/snap-tests-global/command-env-install-parallel/parallel-pkg-b/cli.js b/packages/cli/snap-tests-global/command-env-install-parallel/parallel-pkg-b/cli.js old mode 100644 new mode 100755 diff --git a/packages/cli/snap-tests-global/command-env-install-parallel/snap.txt b/packages/cli/snap-tests-global/command-env-install-parallel/snap.txt index 721dd1d80b..4dcf1fbef2 100644 --- a/packages/cli/snap-tests-global/command-env-install-parallel/snap.txt +++ b/packages/cli/snap-tests-global/command-env-install-parallel/snap.txt @@ -1,9 +1,9 @@ > vp install -g --concurrency 1 ./parallel-pkg-a ./parallel-pkg-b # Install multiple global packages info: Installing 2 global packages with Node.js -✓ Installed ./parallel-pkg-a +✓ Installed parallel-pkg-a Bins: parallel-a -✓ Installed ./parallel-pkg-b +✓ Installed parallel-pkg-b Bins: parallel-b > parallel-a && parallel-b # Both binaries should be callable diff --git a/packages/cli/snap-tests-global/command-env-install-version-alias/snap.txt b/packages/cli/snap-tests-global/command-env-install-version-alias/snap.txt index 58510ae5c6..c8152d1d73 100644 --- a/packages/cli/snap-tests-global/command-env-install-version-alias/snap.txt +++ b/packages/cli/snap-tests-global/command-env-install-version-alias/snap.txt @@ -1,6 +1,6 @@ > vp install -g --node lts ./command-env-install-version-alias-pkg # Install with LTS alias info: Installing 1 global package with Node.js -✓ Installed ./command-env-install-version-alias-pkg +✓ Installed command-env-install-version-alias-pkg Bins: command-env-install-version-alias-pkg-cli > cat $VP_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('LTS major >= 20:', v >= 20)" # Verify LTS version @@ -11,7 +11,7 @@ Uninstalled command-env-install-version-alias-pkg > vp install -g --node latest ./command-env-install-version-alias-pkg # Install with latest alias info: Installing 1 global package with Node.js -✓ Installed ./command-env-install-version-alias-pkg +✓ Installed command-env-install-version-alias-pkg Bins: command-env-install-version-alias-pkg-cli > cat $VP_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('Latest major >= 20:', v >= 20)" # Verify latest version diff --git a/packages/cli/snap-tests-global/env-install-binary-conflict/snap.txt b/packages/cli/snap-tests-global/env-install-binary-conflict/snap.txt index 185390d749..3355ed668a 100644 --- a/packages/cli/snap-tests-global/env-install-binary-conflict/snap.txt +++ b/packages/cli/snap-tests-global/env-install-binary-conflict/snap.txt @@ -1,41 +1,41 @@ > vp install -g ./env-binary-conflict-pkg-a # Install pkg-a which provides env-binary-conflict-cli binary info: Installing 1 global package with Node.js -✓ Installed ./env-binary-conflict-pkg-a +✓ Installed env-binary-conflict-pkg-a Bins: env-binary-conflict-cli > cat $VP_HOME/bins/env-binary-conflict-cli.json # Bin config should point to pkg-a { "name": "env-binary-conflict-cli", - "package": "./env-binary-conflict-pkg-a", + "package": "env-binary-conflict-pkg-a", "version": "1.0.0", "nodeVersion": "22.22.0", "source": "vp" } [1]> vp install -g ./env-binary-conflict-pkg-b # Try to install pkg-b without force - should fail info: Installing 1 global package with Node.js -error: Failed to install ./env-binary-conflict-pkg-b: Executable 'env-binary-conflict-cli' is already installed by ./env-binary-conflict-pkg-a +error: Failed to install env-binary-conflict-pkg-b: Executable 'env-binary-conflict-cli' is already installed by env-binary-conflict-pkg-a -Please remove ./env-binary-conflict-pkg-a before installing ./env-binary-conflict-pkg-b, or use --force to auto-replace +Please remove env-binary-conflict-pkg-a before installing env-binary-conflict-pkg-b, or use --force to auto-replace > cat $VP_HOME/bins/env-binary-conflict-cli.json # Bin config should still point to pkg-a { "name": "env-binary-conflict-cli", - "package": "./env-binary-conflict-pkg-a", + "package": "env-binary-conflict-pkg-a", "version": "1.0.0", "nodeVersion": "22.22.0", "source": "vp" } > vp install -g --force ./env-binary-conflict-pkg-b # Force install pkg-b - should auto-uninstall pkg-a info: Installing 1 global package with Node.js -Uninstalling ./env-binary-conflict-pkg-a (conflicts with ./env-binary-conflict-pkg-b)... -Uninstalled ./env-binary-conflict-pkg-a -✓ Installed ./env-binary-conflict-pkg-b +Uninstalling env-binary-conflict-pkg-a (conflicts with env-binary-conflict-pkg-b)... +Uninstalled env-binary-conflict-pkg-a +✓ Installed env-binary-conflict-pkg-b Bins: env-binary-conflict-cli > cat $VP_HOME/bins/env-binary-conflict-cli.json # Bin config should now point to pkg-b { "name": "env-binary-conflict-cli", - "package": "./env-binary-conflict-pkg-b", + "package": "env-binary-conflict-pkg-b", "version": "2.0.0", "nodeVersion": "22.22.0", "source": "vp" diff --git a/packages/cli/snap-tests-global/npm-global-install-already-linked/snap.txt b/packages/cli/snap-tests-global/npm-global-install-already-linked/snap.txt index 9650a8d433..57a939d2c4 100644 --- a/packages/cli/snap-tests-global/npm-global-install-already-linked/snap.txt +++ b/packages/cli/snap-tests-global/npm-global-install-already-linked/snap.txt @@ -3,7 +3,7 @@ > vp install -g ./npm-global-linked-pkg # First install via vp (creates managed shim) info: Installing 1 global package with Node.js -✓ Installed ./npm-global-linked-pkg +✓ Installed npm-global-linked-pkg Bins: npm-global-linked-cli > npm-global-linked-cli # Should be callable via the link @@ -12,7 +12,7 @@ npm-global-linked-cli works > npm install -g ./npm-global-linked-pkg # Should NOT show hint (binary already exists) added 1 package in ms -Skipped 'npm-global-linked-cli': managed by `vp install -g ./npm-global-linked-pkg`. Run `vp uninstall -g ./npm-global-linked-pkg` to remove it first. +Skipped 'npm-global-linked-cli': managed by `vp install -g npm-global-linked-pkg`. Run `vp uninstall -g npm-global-linked-pkg` to remove it first. > vp remove -g npm-global-linked-pkg # Cleanup Uninstalled npm-global-linked-pkg diff --git a/packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/snap.txt b/packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/snap.txt index 6d08f8a9a5..6fe653db93 100644 --- a/packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/snap.txt +++ b/packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/snap.txt @@ -1,12 +1,12 @@ > vp install -g ./npm-global-vp-managed-pkg # Install via vp (creates managed shim) info: Installing 1 global package with Node.js -✓ Installed ./npm-global-vp-managed-pkg +✓ Installed npm-global-vp-managed-pkg Bins: npm-global-vp-managed-cli > npm install -g ./npm-global-vp-managed-pkg # npm install (should warn about conflict) added 1 package in ms -Skipped 'npm-global-vp-managed-cli': managed by `vp install -g ./npm-global-vp-managed-pkg`. Run `vp uninstall -g ./npm-global-vp-managed-pkg` to remove it first. +Skipped 'npm-global-vp-managed-cli': managed by `vp install -g npm-global-vp-managed-pkg`. Run `vp uninstall -g npm-global-vp-managed-pkg` to remove it first. > npm uninstall -g npm-global-vp-managed-pkg # npm uninstall should NOT remove the vp-managed shim diff --git a/packages/cli/snap-tests-global/shim-recursive-package-binary/snap.txt b/packages/cli/snap-tests-global/shim-recursive-package-binary/snap.txt index a8a56719e0..8744528a8a 100644 --- a/packages/cli/snap-tests-global/shim-recursive-package-binary/snap.txt +++ b/packages/cli/snap-tests-global/shim-recursive-package-binary/snap.txt @@ -1,6 +1,6 @@ > vp install -g ./recursive-cli-pkg # Install test package info: Installing 1 global package with Node.js -✓ Installed ./recursive-cli-pkg +✓ Installed recursive-cli-pkg Bins: recursive-cli > recursive-cli # Outer call triggers recursive inner call through shim diff --git a/packages/test/build.ts b/packages/test/build.ts index dd313749cf..ab9c9bda89 100644 --- a/packages/test/build.ts +++ b/packages/test/build.ts @@ -67,6 +67,8 @@ const vendorDir = resolve(distDir, 'vendor'); const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core'; const TEST_PACKAGE_NAME = '@voidzero-dev/vite-plus-test'; +await rm(distDir, { recursive: true, force: true }); + // @vitest/* packages to copy (not bundle) to preserve browser/Node.js separation // These are copied from node_modules to dist/@vitest/ to avoid shared chunks // that mix Node.js-only code with browser code diff --git a/packages/tools/src/__tests__/local-cli.spec.ts b/packages/tools/src/__tests__/local-cli.spec.ts new file mode 100644 index 0000000000..540e927283 --- /dev/null +++ b/packages/tools/src/__tests__/local-cli.spec.ts @@ -0,0 +1,34 @@ +import path from 'node:path'; + +import { describe, expect, test } from '@voidzero-dev/vite-plus-test'; + +import { getPnpmInvocation } from '../local-cli.ts'; + +describe('getPnpmInvocation()', () => { + test('falls back to pnpm binary when npm_execpath is unset', () => { + const expectedCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + + expect(getPnpmInvocation(undefined)).toEqual({ + command: expectedCommand, + args: [], + }); + }); + + test('runs pnpm through node when npm_execpath points to a JS script', () => { + const execPath = path.join('/tmp', 'pnpm.cjs'); + + expect(getPnpmInvocation(execPath)).toEqual({ + command: process.execPath, + args: [execPath], + }); + }); + + test('runs pnpm directly when npm_execpath points to a native binary', () => { + const execPath = path.join('/home/runner/setup-pnpm/node_modules/.bin/store', 'pnpm'); + + expect(getPnpmInvocation(execPath)).toEqual({ + command: execPath, + args: [], + }); + }); +}); diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 5a6fa9b58e..1aa106db9e 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -25,6 +25,18 @@ switch (subcommand) { const { installGlobalCli } = await import('./install-global-cli.ts'); installGlobalCli(); break; + case 'build-local-cli': + const { runBuildLocalCli } = await import('./local-cli.ts'); + runBuildLocalCli(process.argv.slice(3)); + break; + case 'local-cli': + const { runLocalCli } = await import('./local-cli.ts'); + runLocalCli(process.argv.slice(3)); + break; + case 'snap-test-global-local': + const { runLocalGlobalSnapTest } = await import('./local-cli.ts'); + runLocalGlobalSnapTest(process.argv.slice(3)); + break; case 'brand-vite': const { brandVite } = await import('./brand-vite.ts'); brandVite(); @@ -32,7 +44,7 @@ switch (subcommand) { default: console.error(`Unknown subcommand: ${subcommand}`); console.error( - 'Available subcommands: snap-test, replace-file-content, sync-remote, json-sort, merge-peer-deps, install-global-cli, brand-vite', + 'Available subcommands: snap-test, replace-file-content, sync-remote, json-sort, merge-peer-deps, install-global-cli, build-local-cli, local-cli, snap-test-global-local, brand-vite', ); process.exit(1); } diff --git a/packages/tools/src/local-cli.ts b/packages/tools/src/local-cli.ts new file mode 100644 index 0000000000..a88b8846eb --- /dev/null +++ b/packages/tools/src/local-cli.ts @@ -0,0 +1,415 @@ +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const isWindows = process.platform === 'win32'; +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const cliDistDir = path.join(repoRoot, 'packages', 'cli', 'dist'); +const cliBinPath = path.join(cliDistDir, 'bin.js'); +const testCliPath = path.join(repoRoot, 'packages', 'test', 'dist', 'cli.js'); +const localVpBinaryName = isWindows ? 'vp.exe' : 'vp'; +const defaultLocalVpPath = path.join(repoRoot, 'target', 'debug', localVpBinaryName); +const viteRepoDir = path.join(repoRoot, 'vite'); +const legacyViteRepoDir = path.join(repoRoot, 'rolldown-vite'); +const rolldownRepoDir = path.join(repoRoot, 'rolldown'); +const rolldownPackageDir = path.join(rolldownRepoDir, 'packages', 'rolldown'); +const rolldownPackageJsonPath = path.join(rolldownPackageDir, 'package.json'); +const rolldownSrcDir = path.join(rolldownPackageDir, 'src'); +const toolBinPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'bin.js'); +const buildHint = 'pnpm build:cli'; +const bootstrapHint = 'pnpm bootstrap:dev'; +const installHint = 'pnpm install:dev'; +const pnpmExecPath = process.env.npm_execpath; +const pnpmBin = isWindows ? 'pnpm.cmd' : 'pnpm'; +const cargoBin = isWindows ? 'cargo.exe' : 'cargo'; +const requireFromRolldown = createRequire(rolldownPackageJsonPath); + +type CommandOptions = { + cwd?: string; + env?: NodeJS.ProcessEnv; + hint?: string; +}; + +type LocalCliArtifacts = { + vpPath: string; + vpBinDir: string; +}; + +function failMissing(pathname: string, description: string): never { + console.error(`Missing ${description}: ${pathname}`); + console.error(`Run "${bootstrapHint}" from a fresh clone, or "${buildHint}" after setup.`); + process.exit(1); +} + +function getTargetDirs(): string[] { + return [ + ...new Set( + [process.env.CARGO_TARGET_DIR, path.join(repoRoot, 'target')].filter( + (targetDir): targetDir is string => Boolean(targetDir), + ), + ), + ]; +} + +function findLocalVpBinary(): string | null { + const profiles = ['debug', 'release']; + + for (const targetDir of getTargetDirs()) { + for (const profile of profiles) { + const directPath = path.join(targetDir, profile, localVpBinaryName); + if (existsSync(directPath)) { + return directPath; + } + } + + try { + for (const entry of readdirSync(targetDir).toSorted()) { + for (const profile of profiles) { + const nestedPath = path.join(targetDir, entry, profile, localVpBinaryName); + if (existsSync(nestedPath)) { + return nestedPath; + } + } + } + } catch { + continue; + } + } + + return null; +} + +function ensureLocalCliReady(options?: { needsTestCli?: boolean }): LocalCliArtifacts { + if (!existsSync(cliBinPath)) { + failMissing(cliBinPath, 'local CLI bundle'); + } + const vpPath = findLocalVpBinary(); + if (!vpPath) { + failMissing(defaultLocalVpPath, 'local vp binary'); + } + if (options?.needsTestCli && !existsSync(testCliPath)) { + failMissing(testCliPath, 'local test CLI bundle'); + } + + return { + vpPath, + vpBinDir: path.dirname(vpPath), + }; +} + +function localCliEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + VITE_GLOBAL_CLI_JS_SCRIPTS_DIR: cliDistDir, + }; +} + +function rolldownBindingCandidates() { + switch (process.platform) { + case 'android': + if (process.arch === 'arm64') { + return ['@rolldown/binding-android-arm64/package.json']; + } + if (process.arch === 'arm') { + return ['@rolldown/binding-android-arm-eabi/package.json']; + } + return []; + case 'darwin': + if (process.arch === 'arm64') { + return [ + '@rolldown/binding-darwin-universal/package.json', + '@rolldown/binding-darwin-arm64/package.json', + ]; + } + if (process.arch === 'x64') { + return [ + '@rolldown/binding-darwin-universal/package.json', + '@rolldown/binding-darwin-x64/package.json', + ]; + } + return []; + case 'freebsd': + if (process.arch === 'arm64') { + return ['@rolldown/binding-freebsd-arm64/package.json']; + } + if (process.arch === 'x64') { + return ['@rolldown/binding-freebsd-x64/package.json']; + } + return []; + case 'linux': + if (process.arch === 'arm') { + return [ + '@rolldown/binding-linux-arm-gnueabihf/package.json', + '@rolldown/binding-linux-arm-musleabihf/package.json', + ]; + } + if (process.arch === 'arm64') { + return [ + '@rolldown/binding-linux-arm64-gnu/package.json', + '@rolldown/binding-linux-arm64-musl/package.json', + ]; + } + if (process.arch === 'loong64') { + return [ + '@rolldown/binding-linux-loong64-gnu/package.json', + '@rolldown/binding-linux-loong64-musl/package.json', + ]; + } + if (process.arch === 'ppc64') { + return ['@rolldown/binding-linux-ppc64-gnu/package.json']; + } + if (process.arch === 'riscv64') { + return [ + '@rolldown/binding-linux-riscv64-gnu/package.json', + '@rolldown/binding-linux-riscv64-musl/package.json', + ]; + } + if (process.arch === 's390x') { + return ['@rolldown/binding-linux-s390x-gnu/package.json']; + } + if (process.arch === 'x64') { + return [ + '@rolldown/binding-linux-x64-gnu/package.json', + '@rolldown/binding-linux-x64-musl/package.json', + ]; + } + return []; + case 'win32': + if (process.arch === 'arm64') { + return ['@rolldown/binding-win32-arm64-msvc/package.json']; + } + if (process.arch === 'ia32') { + return ['@rolldown/binding-win32-ia32-msvc/package.json']; + } + if (process.arch === 'x64') { + return [ + '@rolldown/binding-win32-x64-msvc/package.json', + '@rolldown/binding-win32-x64-gnu/package.json', + ]; + } + return []; + default: + return []; + } +} + +function hasRolldownPackagedBinding() { + const candidates = rolldownBindingCandidates(); + if (candidates.length === 0) { + return true; + } + + for (const candidate of candidates) { + try { + requireFromRolldown.resolve(candidate); + return true; + } catch { + continue; + } + } + + return false; +} + +function materializeRolldownPackagedBindings() { + for (const candidate of rolldownBindingCandidates()) { + let packageJsonPath: string; + try { + packageJsonPath = requireFromRolldown.resolve(candidate); + } catch { + continue; + } + + const packageDir = path.dirname(packageJsonPath); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { + files?: string[]; + main?: string; + }; + const bindingFile = + packageJson.main ?? packageJson.files?.find((file) => file.endsWith('.node')); + if (!bindingFile) { + continue; + } + + const sourcePath = path.join(packageDir, bindingFile); + const targetPath = path.join(rolldownSrcDir, path.basename(bindingFile)); + if (!existsSync(targetPath)) { + copyFileSync(sourcePath, targetPath); + } + } +} + +function ensureBuildWorkspaceReady() { + if (!existsSync(viteRepoDir)) { + console.error(`Missing local vite checkout: ${viteRepoDir}`); + if (existsSync(legacyViteRepoDir)) { + console.error( + `Found legacy checkout at ${legacyViteRepoDir}. This repo now expects the upstream Vite checkout at ./vite.`, + ); + console.error(`Run "${installHint}" to recreate the canonical layout.`); + } else { + console.error( + `Run "${installHint}" to fetch the local upstream checkouts, or "${bootstrapHint}" to prepare and build the local CLI.`, + ); + } + process.exit(1); + } + + if (!existsSync(rolldownRepoDir)) { + console.error(`Missing local rolldown checkout: ${rolldownRepoDir}`); + console.error( + `Run "${installHint}" to fetch the local upstream checkouts, or "${bootstrapHint}" to prepare and build the local CLI.`, + ); + process.exit(1); + } + + if (!existsSync(rolldownPackageJsonPath) || !existsSync(rolldownSrcDir)) { + console.error(`Incomplete local rolldown checkout: ${rolldownPackageDir}`); + console.error( + `Run "${installHint}" to fetch the local upstream checkouts, or "${bootstrapHint}" to prepare and build the local CLI.`, + ); + process.exit(1); + } +} + +function runCommand(step: string, command: string, args: string[], options: CommandOptions = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + stdio: 'inherit', + }); + + if (!result.error && result.status === 0) { + return; + } + + console.error(`\n${step} failed.`); + if (result.error) { + console.error(result.error.message); + } + if (options.hint) { + console.error(options.hint); + } + process.exit(result.status ?? 1); +} + +export function getPnpmInvocation(execPath: string | undefined) { + if (!execPath) { + return { + command: pnpmBin, + args: [] as string[], + }; + } + + const ext = path.extname(execPath); + if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { + return { + command: process.execPath, + args: [execPath], + }; + } + + return { + command: execPath, + args: [] as string[], + }; +} + +function runPnpmCommand(step: string, args: string[], options: CommandOptions = {}) { + const { command, args: baseArgs } = getPnpmInvocation(pnpmExecPath); + + runCommand(step, command, [...baseArgs, ...args], options); +} + +function exitWith(result: ReturnType): never { + if (result.error) { + console.error(result.error.message); + process.exit(1); + } + process.exit(result.status ?? 1); +} + +export function runLocalCli(args: string[]) { + const { vpPath } = ensureLocalCliReady({ needsTestCli: args[0] === 'test' }); + + const result = spawnSync(vpPath, args, { + cwd: process.cwd(), + env: localCliEnv(), + stdio: 'inherit', + }); + exitWith(result); +} + +export function runLocalGlobalSnapTest(args: string[]) { + const { vpBinDir } = ensureLocalCliReady(); + + const result = spawnSync( + process.execPath, + [ + toolBinPath, + 'snap-test', + '--dir', + 'snap-tests-global', + '--local-vp-bin-dir', + vpBinDir, + ...args, + ], + { + cwd: process.cwd(), + env: localCliEnv(), + stdio: 'inherit', + }, + ); + exitWith(result); +} + +export function runBuildLocalCli(args: string[]) { + const releaseRust = args.includes('--release-rust'); + const localBuildEnv = { + ...process.env, + VITE_PLUS_CLI_DEBUG: '1', + }; + + mkdirSync(path.join(repoRoot, 'tmp'), { recursive: true }); + ensureBuildWorkspaceReady(); + + runPnpmCommand('Build @rolldown/pluginutils', ['--filter', '@rolldown/pluginutils', 'build']); + const hasPackagedBinding = hasRolldownPackagedBinding(); + if (!hasPackagedBinding) { + runPnpmCommand( + 'Build rolldown native binding', + ['--filter', 'rolldown', releaseRust ? 'build-binding:release' : 'build-binding'], + { + hint: 'If this fails, install "cmake" so rolldown can build its native binding from source.', + }, + ); + } else { + materializeRolldownPackagedBindings(); + } + runPnpmCommand('Build rolldown JS glue', ['--filter', 'rolldown', 'build-node'], { + hint: 'If this fails with a missing rolldown native binding, rerun "pnpm install:dev". If the error mentions "cmake", install cmake to build rolldown from source.', + }); + runPnpmCommand('Build vite types', ['-C', 'vite', '--filter', 'vite', 'build-types'], { + hint: 'If this fails because vite dependencies are missing, rerun "pnpm install" from the repo root.', + }); + runPnpmCommand('Build vite-plus core', ['--filter', '@voidzero-dev/vite-plus-core', 'build']); + runPnpmCommand('Build vite-plus test', ['--filter', '@voidzero-dev/vite-plus-test', 'build']); + runPnpmCommand('Build vite-plus prompts', [ + '--filter', + '@voidzero-dev/vite-plus-prompts', + 'build', + ]); + runPnpmCommand('Build vite-plus CLI', ['--filter', 'vite-plus', 'build'], { + env: releaseRust ? process.env : localBuildEnv, + }); + runCommand('Build Rust CLI binaries', cargoBin, [ + 'build', + '-p', + 'vite_global_cli', + '-p', + 'vite_trampoline', + ...(releaseRust ? ['--release'] : []), + ]); +} diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index d234f2bdd7..f3f1a3e9cd 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; @@ -90,6 +91,48 @@ function selectShard(items: T[], index: number, total: number): T[] { } const NPM_GLOBAL_PREFIX_DIR = 'npm-global-lib-for-snap-tests'; +const vpBinaryName = process.platform === 'win32' ? 'vp.exe' : 'vp'; + +function setupLocalGlobalShims(vitePlusHome: string, sourceBinDir: string): string { + const resolvedSourceBinDir = path.resolve(expandHome(sourceBinDir)); + const sourceVpPath = path.join(resolvedSourceBinDir, vpBinaryName); + if (!fs.existsSync(sourceVpPath)) { + throw new Error(`Missing local vp binary for snap tests: ${sourceVpPath}`); + } + + const currentBinDir = path.join(vitePlusHome, 'current', 'bin'); + fs.mkdirSync(currentBinDir, { recursive: true }); + + const localVpPath = path.join(currentBinDir, vpBinaryName); + if (process.platform === 'win32') { + fs.copyFileSync(sourceVpPath, localVpPath); + + const sourceShimPath = path.join(resolvedSourceBinDir, 'vp-shim.exe'); + if (!fs.existsSync(sourceShimPath)) { + throw new Error(`Missing local vp trampoline for snap tests: ${sourceShimPath}`); + } + fs.copyFileSync(sourceShimPath, path.join(currentBinDir, 'vp-shim.exe')); + } else { + fs.symlinkSync(sourceVpPath, localVpPath); + } + + const result = spawnSync(localVpPath, ['env', 'setup', '--refresh'], { + env: { + ...process.env, + VP_HOME: vitePlusHome, + }, + encoding: 'utf-8', + }); + + if (!result.error && result.status === 0) { + return path.join(vitePlusHome, 'bin'); + } + + const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + throw new Error( + `Failed to prepare local global shims for snap tests.${output ? `\n${output}` : ''}`, + ); +} function resolveGlobalCliScriptsDir(casesDir: string): string { const candidates = [ @@ -306,6 +349,7 @@ export async function snapTest() { options: { dir: { type: 'string' }, 'bin-dir': { type: 'string' }, + 'local-vp-bin-dir': { type: 'string' }, shard: { type: 'string' }, }, }); @@ -342,7 +386,11 @@ export async function snapTest() { } } - const vitePlusHome = path.join(homedir(), '.vite-plus'); + const vitePlusHome = path.join(tempTmpDir, 'vite-plus-home'); + fs.mkdirSync(vitePlusHome, { recursive: true }); + const effectiveBinDir = values['local-vp-bin-dir'] + ? setupLocalGlobalShims(vitePlusHome, values['local-vp-bin-dir']) + : values['bin-dir']; // Remove .previous-version so command-upgrade-rollback snap test is stable const previousVersionPath = path.join(vitePlusHome, '.previous-version'); @@ -424,7 +472,7 @@ export async function snapTest() { const selectedCases = shard ? selectShard(validCaseNames, shard.index, shard.total) : validCaseNames; - const globalCliScriptsDir = values['bin-dir'] ? resolveGlobalCliScriptsDir(casesDir) : undefined; + const globalCliScriptsDir = effectiveBinDir ? resolveGlobalCliScriptsDir(casesDir) : undefined; if (values['bin-dir']) { assertGlobalCliBinaryMatchesCheckout(values['bin-dir'], casesDir); } @@ -435,7 +483,14 @@ export async function snapTest() { const stepsPath = path.join(casesDir, caseName, 'steps.json'); const steps: Steps = JSON.parse(fs.readFileSync(stepsPath, 'utf-8')); const task = () => - runTestCase(caseName, tempTmpDir, casesDir, values['bin-dir'], globalCliScriptsDir); + runTestCase( + caseName, + tempTmpDir, + casesDir, + vitePlusHome, + effectiveBinDir, + globalCliScriptsDir, + ); if (steps.serial) { serialTasks.push(task); } else { @@ -558,6 +613,7 @@ async function runTestCase( name: string, tempTmpDir: string, casesDir: string, + vitePlusHome: string, binDir?: string, globalCliScriptsDir?: string, ) { @@ -590,7 +646,7 @@ async function runTestCase( NO_COLOR: 'true', // set CI=true make sure snap-tests are stable on GitHub Actions CI: 'true', - VP_HOME: path.join(homedir(), '.vite-plus'), + VP_HOME: vitePlusHome, // Set git identity so `git commit` works on CI runners without global git config GIT_AUTHOR_NAME: 'Test', GIT_COMMITTER_NAME: 'Test', diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 4ffbf0a5bd..79d5ca553b 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -31,6 +31,7 @@ export function replaceUnstableOutput(output: string, cwd?: string) { replacePathToken(cwd, ''); const parent = path.dirname(cwd); if (parent !== '/') { + replacePathToken(path.join(parent, 'vite-plus-home'), ''); replacePathToken(parent, '/..'); } } diff --git a/scripts/setup-local-dev.mjs b/scripts/setup-local-dev.mjs new file mode 100644 index 0000000000..20f4e279f2 --- /dev/null +++ b/scripts/setup-local-dev.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { existsSync, lstatSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const isWindows = process.platform === 'win32'; +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const gitBin = isWindows ? 'git.exe' : 'git'; +const pnpmBin = isWindows ? 'pnpm.cmd' : 'pnpm'; +const pnpmLockfilePath = path.join(repoRoot, 'pnpm-lock.yaml'); +const rolldownRepoDir = path.join(repoRoot, 'rolldown'); +const rolldownPackageJsonRelativePath = 'packages/rolldown/package.json'; +const upstreamVersions = JSON.parse( + readFileSync(path.join(repoRoot, 'packages', 'tools', '.upstream-versions.json'), 'utf-8'), +); + +function log(message) { + process.stdout.write(`[setup-dev] ${message}\n`); +} + +function fail(message) { + console.error(`[setup-dev] ${message}`); + process.exit(1); +} + +function canonicalRemote(url) { + return url + .trim() + .replace(/^git@github\.com:/, 'https://github.com/') + .replace(/^ssh:\/\/git@github\.com\//, 'https://github.com/') + .replace(/\.git$/, '') + .replace(/\/$/, ''); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repoRoot, + stdio: options.stdio ?? 'inherit', + encoding: options.encoding ?? 'utf-8', + }); + + if (result.error) { + fail(result.error.message); + } + + if (result.status !== 0) { + const rendered = [command, ...args].join(' '); + fail(`Command failed (${result.status}): ${rendered}`); + } + + return result; +} + +function capture(command, args, cwd) { + return run(command, args, { + cwd, + stdio: 'pipe', + encoding: 'utf-8', + }).stdout.trim(); +} + +function stringifyJson(value) { + return JSON.stringify(value, null, 2) + '\n'; +} + +function rolldownPackageJsonPath(rootDir = rolldownRepoDir) { + return path.join(rootDir, rolldownPackageJsonRelativePath); +} + +function isGitRepo(dir) { + const result = spawnSync(gitBin, ['rev-parse', '--git-dir'], { + cwd: dir, + stdio: 'ignore', + }); + return result.status === 0; +} + +function isDirty(dir) { + return capture(gitBin, ['status', '--porcelain'], dir) !== ''; +} + +function ensureExpectedRemote(name, dir, repoUrl) { + const actual = canonicalRemote(capture(gitBin, ['remote', 'get-url', 'origin'], dir)); + const expected = canonicalRemote(repoUrl); + if (actual !== expected) { + fail( + `Unexpected remote for ${name}: ${actual}. Expected ${expected}. Please fix the checkout or remove ${dir} and rerun this command.`, + ); + } +} + +function cloneCheckout(name, repoUrl, branch, hash) { + log(`Cloning ${name} from ${repoUrl} (${branch})...`); + run(gitBin, ['clone', '--branch', branch, repoUrl, name]); + if (hash) { + run(gitBin, ['reset', '--hard', hash], { + cwd: path.join(repoRoot, name), + }); + } +} + +function rolldownBindingCandidates() { + switch (process.platform) { + case 'android': + if (process.arch === 'arm64') { + return ['@rolldown/binding-android-arm64']; + } + if (process.arch === 'arm') { + return ['@rolldown/binding-android-arm-eabi']; + } + return []; + case 'darwin': + if (process.arch === 'arm64') { + return ['@rolldown/binding-darwin-universal', '@rolldown/binding-darwin-arm64']; + } + if (process.arch === 'x64') { + return ['@rolldown/binding-darwin-universal', '@rolldown/binding-darwin-x64']; + } + return []; + case 'freebsd': + if (process.arch === 'arm64') { + return ['@rolldown/binding-freebsd-arm64']; + } + if (process.arch === 'x64') { + return ['@rolldown/binding-freebsd-x64']; + } + return []; + case 'linux': + if (process.arch === 'arm') { + return ['@rolldown/binding-linux-arm-gnueabihf', '@rolldown/binding-linux-arm-musleabihf']; + } + if (process.arch === 'arm64') { + return ['@rolldown/binding-linux-arm64-gnu', '@rolldown/binding-linux-arm64-musl']; + } + if (process.arch === 'loong64') { + return ['@rolldown/binding-linux-loong64-gnu', '@rolldown/binding-linux-loong64-musl']; + } + if (process.arch === 'ppc64') { + return ['@rolldown/binding-linux-ppc64-gnu']; + } + if (process.arch === 'riscv64') { + return ['@rolldown/binding-linux-riscv64-gnu', '@rolldown/binding-linux-riscv64-musl']; + } + if (process.arch === 's390x') { + return ['@rolldown/binding-linux-s390x-gnu']; + } + if (process.arch === 'x64') { + return ['@rolldown/binding-linux-x64-gnu', '@rolldown/binding-linux-x64-musl']; + } + return []; + case 'win32': + if (process.arch === 'arm64') { + return ['@rolldown/binding-win32-arm64-msvc']; + } + if (process.arch === 'ia32') { + return ['@rolldown/binding-win32-ia32-msvc']; + } + if (process.arch === 'x64') { + return ['@rolldown/binding-win32-x64-msvc', '@rolldown/binding-win32-x64-gnu']; + } + return []; + default: + return []; + } +} + +function withRolldownHostBindings(pkg) { + const candidates = rolldownBindingCandidates(); + if (candidates.length === 0) { + return { changed: false, pkg }; + } + + const optionalDependencies = { + ...pkg.optionalDependencies, + }; + + let changed = false; + for (const candidate of candidates) { + if (!optionalDependencies[candidate]) { + optionalDependencies[candidate] = pkg.version; + changed = true; + } + } + + if (!changed) { + return { changed: false, pkg }; + } + + return { + changed: true, + pkg: { + ...pkg, + optionalDependencies, + }, + }; +} + +function ensureRolldownHostBindings() { + const packageJsonPath = rolldownPackageJsonPath(); + const { changed, pkg } = withRolldownHostBindings( + JSON.parse(readFileSync(packageJsonPath, 'utf-8')), + ); + if (!changed) { + return; + } + + writeFileSync(packageJsonPath, stringifyJson(pkg), 'utf-8'); + log(`Added host rolldown bindings to ${packageJsonPath}`); +} + +function hasOnlyManagedRolldownBindingsChange(dir) { + const statusEntries = capture(gitBin, ['status', '--porcelain'], dir).split('\n').filter(Boolean); + if (statusEntries.length !== 1 || statusEntries[0].slice(3) !== rolldownPackageJsonRelativePath) { + return false; + } + + const { changed, pkg } = withRolldownHostBindings( + JSON.parse(capture(gitBin, ['show', `HEAD:${rolldownPackageJsonRelativePath}`], dir)), + ); + if (!changed) { + return false; + } + + return readFileSync(rolldownPackageJsonPath(dir), 'utf-8') === stringifyJson(pkg); +} + +function syncCleanCheckout(name, config) { + const dir = path.join(repoRoot, name); + + if (!existsSync(dir)) { + cloneCheckout(name, config.repo, config.branch, config.hash); + return; + } + + if (lstatSync(dir).isSymbolicLink()) { + log(`Using existing symlinked ${name} checkout at ${dir}`); + return; + } + + if (!isGitRepo(dir)) { + fail(`${dir} exists but is not a git repository.`); + } + + ensureExpectedRemote(name, dir, config.repo); + + const hasManagedRolldownBindingsChange = + name === 'rolldown' && isDirty(dir) && hasOnlyManagedRolldownBindingsChange(dir); + if (isDirty(dir) && !hasManagedRolldownBindingsChange) { + log(`Keeping existing dirty ${name} checkout at ${dir}`); + return; + } + if (hasManagedRolldownBindingsChange) { + log(`Ignoring managed rolldown host binding diff at ${dir}`); + } + + log(`Updating clean ${name} checkout...`); + run(gitBin, ['fetch', 'origin', '--tags'], { cwd: dir }); + run(gitBin, ['checkout', config.branch], { cwd: dir }); + + if (config.hash) { + run(gitBin, ['reset', '--hard', config.hash], { cwd: dir }); + } else { + run(gitBin, ['reset', '--hard', `origin/${config.branch}`], { cwd: dir }); + } +} + +function migrateLegacyViteCheckout() { + const viteDir = path.join(repoRoot, 'vite'); + const legacyDir = path.join(repoRoot, 'rolldown-vite'); + + if (existsSync(viteDir) || !existsSync(legacyDir)) { + return; + } + + if (lstatSync(legacyDir).isSymbolicLink()) { + fail(`Found legacy symlinked checkout at ${legacyDir}. Remove it and rerun this command.`); + } + + if (!isGitRepo(legacyDir)) { + fail(`Found legacy directory ${legacyDir}, but it is not a git repository.`); + } + + ensureExpectedRemote('rolldown-vite', legacyDir, upstreamVersions.vite.repo); + + if (isDirty(legacyDir)) { + fail( + `Found legacy checkout at ${legacyDir} with local changes. Rename it to ./vite or clean it before rerunning this command.`, + ); + } + + log(`Migrating legacy ${legacyDir} checkout to ${viteDir}...`); + renameSync(legacyDir, viteDir); +} + +function main() { + migrateLegacyViteCheckout(); + + syncCleanCheckout('rolldown', upstreamVersions.rolldown); + syncCleanCheckout('vite', upstreamVersions.vite); + ensureRolldownHostBindings(); + + const originalLockfile = existsSync(pnpmLockfilePath) + ? readFileSync(pnpmLockfilePath, 'utf-8') + : null; + log('Installing workspace dependencies...'); + try { + run(pnpmBin, ['install', '--config.allow-unused-patches=true']); + } finally { + if (originalLockfile !== null) { + writeFileSync(pnpmLockfilePath, originalLockfile, 'utf-8'); + } + } +} + +main();