From ae93e93746e524661c7b0497a5fee7bc001b8209 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Wed, 18 Mar 2026 12:10:08 +0000 Subject: [PATCH] feat(env): use trampoline exe instead of .cmd wrappers on Windows (#981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace Windows `.cmd` shim wrappers with lightweight trampoline `.exe` binaries - Eliminates the "Terminate batch job (Y/N)?" prompt on Ctrl+C - The trampoline detects its tool name from its own filename, sets `VITE_PLUS_SHIM_TOOL` env var, spawns `vp.exe`, ignores Ctrl+C (child handles it), and propagates the exit code - Thanks for the solution suggestion from @sunfkny ## Changes - Add `crates/vite_trampoline/` — minimal Windows trampoline binary (~100-150KB) - Update shim detection (`detect_shim_tool`) to check `VITE_PLUS_SHIM_TOOL` env var before `argv[0]` - Replace `.cmd`/`.sh` wrapper creation with trampoline `.exe` copying in `setup.rs` and `global_install.rs` - Add rename-before-copy pattern for refreshing `bin/vp.exe` while it's running - Add legacy `.cmd`/`.sh` cleanup during `vp env setup --refresh` - Update CI to build and distribute `vp-shim.exe` for Windows targets - Update `install.ps1`, `install.sh`, `publish-native-addons.ts` for trampoline distribution - Update `extract_platform_package` in upgrade path to also extract `vp-shim.exe` - Update npm-link conflict detection to recognize `.exe` shims - Add RFC document and update existing RFCs (`env-command`, `upgrade-command`, `vpx-command`) ## Manual testing (Windows) ### 1\. Fresh install via PowerShell ```powershell Remove-Item -Recurse -Force "$env:USERPROFILE\.vite-plus" -ErrorAction SilentlyContinue & ./packages/cli/install.ps1 dir "$env:USERPROFILE\.vite-plus\bin\*.exe" dir "$env:USERPROFILE\.vite-plus\bin\*.cmd" ``` - [x] Trampoline `.exe` shims created for vp, node, npm, npx, vpx - [x] No legacy `.cmd` wrappers for core tools (only `vp-use.cmd` if present) - [x] `node --version` works - [x] `npm --version` works - [x] `vp --version` works ### 2\. Fresh install via Git Bash ([install.sh](http://install.sh)) ```bash rm -rf ~/.vite-plus bash ./packages/cli/install.sh ls -la ~/.vite-plus/current/bin/vp-shim.exe ``` - [x] `vp-shim.exe` copied alongside `vp.exe` in `current/bin/` - [x] `node --version` works - [x] `npm --version` works ### 3\. Ctrl+C behavior (the main fix) ```powershell vp dev # Press Ctrl+C in each shell ``` - [x] cmd.exe: clean exit, NO "Terminate batch job (Y/N)?" prompt - [x] PowerShell: clean exit - [x] Git Bash: clean exit ### 4\. Exit code propagation ```powershell node -e "process.exit(42)"; echo $LASTEXITCODE npm run nonexistent-script; echo $LASTEXITCODE ``` - [x] Exit code 42 propagated correctly - [x] Non-zero exit code from failed npm command ### 5\. Nested shim invocations (recursion marker) ```powershell npm --version pnpm --version ``` - [x] `npm --version` prints version (npm spawns node internally) - [x] `pnpm --version` prints version (pnpm spawns node internally) ### 6\. `vp env setup --refresh` (running exe overwrite) ```powershell vp env setup --refresh node --version dir "$env:USERPROFILE\.vite-plus\bin\*.old" ``` - [x] Refresh succeeds without "sharing violation" error - [x] Shims still work after refresh - [x] `.old` files cleaned up (or only one recent if vp.exe was still running) ### 7\. `vp install -g` / `vp remove -g` (managed package shims) ```powershell vp install -g typescript where.exe tsc tsc --version vp remove -g typescript dir "$env:USERPROFILE\.vite-plus\bin\tsc*" ``` - [x] `tsc.exe` created (NOT `tsc.cmd`) - [x] `tsc --version` works - [x] After remove: no `tsc.exe`, `tsc.cmd`, or extensionless `tsc` left behind ### 8\. `npm install -g` auto-link (npm-managed packages) ```powershell npm install -g cowsay where.exe cowsay cowsay hello npm uninstall -g cowsay ``` - [x] npm packages use `.cmd` wrapper (NOT trampoline) - [x] `cowsay` works - [x] Uninstall removes the `.cmd` wrapper ### 9\. `vp upgrade` and rollback ```powershell vp --version vp upgrade node --version vp upgrade --rollback vp --version ``` - [x] Upgrade succeeds, shims still work - [x] Rollback succeeds, shims still work ### 10\. Install a pre-trampoline version (downgrade compatibility) ```powershell $env:VITE_PLUS_VERSION = "" & ./packages/cli/install.ps1 Remove-Item Env:VITE_PLUS_VERSION dir "$env:USERPROFILE\.vite-plus\bin\*.exe" dir "$env:USERPROFILE\.vite-plus\bin\*.cmd" ``` - [x] Falls back to `.cmd` wrappers - [x] Stale trampoline `.exe` shims removed (`node.exe`, `npm.exe`, etc.) - [x] `vp --version` works via `.cmd` wrapper ### 11\. `vp env doctor` ```powershell vp env doctor ``` - [x] Shows shims as working, no errors about missing files ### 12\. Cross-shell verification - [x] cmd.exe: `node --version`, `npm --version`, `vp --version` all work - [x] PowerShell: `node --version`, `npm --version`, `vp --version` all work - [x] Git Bash: `node --version`, `npm --version`, `vp --version` all work Closes #835 --- .github/actions/build-upstream/action.yml | 7 + .github/workflows/e2e-test.yml | 7 +- .github/workflows/release.yml | 1 + .github/workflows/test-standalone-install.yml | 33 +- .husky/pre-commit | 0 Cargo.lock | 4 + Cargo.toml | 5 + .../src/commands/env/doctor.rs | 12 +- .../src/commands/env/global_install.rs | 119 +++++--- .../vite_global_cli/src/commands/env/setup.rs | 216 +++++++++---- .../src/commands/upgrade/install.rs | 3 +- crates/vite_global_cli/src/shim/dispatch.rs | 25 +- crates/vite_global_cli/src/shim/mod.rs | 55 ++-- crates/vite_shared/src/env_vars.rs | 8 + crates/vite_trampoline/Cargo.toml | 27 ++ crates/vite_trampoline/src/main.rs | 98 ++++++ package.json | 6 +- packages/cli/install.ps1 | 68 ++++- packages/cli/install.sh | 68 ++++- packages/cli/publish-native-addons.ts | 19 +- packages/tools/src/install-global-cli.ts | 11 + rfcs/env-command.md | 131 ++------ rfcs/trampoline-exe-for-shims.md | 285 ++++++++++++++++++ rfcs/upgrade-command.md | 6 +- rfcs/vpx-command.md | 8 +- 25 files changed, 914 insertions(+), 308 deletions(-) mode change 100644 => 100755 .husky/pre-commit create mode 100644 crates/vite_trampoline/Cargo.toml create mode 100644 crates/vite_trampoline/src/main.rs create mode 100644 rfcs/trampoline-exe-for-shims.md diff --git a/.github/actions/build-upstream/action.yml b/.github/actions/build-upstream/action.yml index 78c88cb834..12b84bbb5b 100644 --- a/.github/actions/build-upstream/action.yml +++ b/.github/actions/build-upstream/action.yml @@ -40,6 +40,7 @@ runs: packages/cli/binding/index.d.cts target/${{ inputs.target }}/release/vp target/${{ inputs.target }}/release/vp.exe + target/${{ inputs.target }}/release/vp-shim.exe key: ${{ steps.cache-key.outputs.key }} # Apply Vite+ branding patches to rolldown-vite source (CI checks out @@ -111,6 +112,11 @@ runs: shell: bash run: cargo build --release --target ${{ inputs.target }} -p vite_global_cli + - name: Build trampoline shim binary (Windows only) + if: steps.cache-restore.outputs.cache-hit != 'true' && contains(inputs.target, 'windows') + shell: bash + run: cargo build --release --target ${{ inputs.target }} -p vite_trampoline + - name: Save NAPI binding cache if: steps.cache-restore.outputs.cache-hit != 'true' uses: actions/cache/save@94b89442628ad1d101e352b7ee38f30e1bef108e # v5 @@ -123,6 +129,7 @@ runs: packages/cli/binding/index.d.cts target/${{ inputs.target }}/release/vp target/${{ inputs.target }}/release/vp.exe + target/${{ inputs.target }}/release/vp-shim.exe key: ${{ steps.cache-key.outputs.key }} # Build vite-plus TypeScript after native bindings are ready diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index b8fa95cf73..0b765e458e 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -112,6 +112,7 @@ jobs: cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. # Copy vp binary for e2e-test job (findVpBinary expects it in target/) cp target/${{ matrix.target }}/release/vp tmp/tgz/vp 2>/dev/null || cp target/${{ matrix.target }}/release/vp.exe tmp/tgz/vp.exe 2>/dev/null || true + cp target/${{ matrix.target }}/release/vp-shim.exe tmp/tgz/vp-shim.exe 2>/dev/null || true ls -la tmp/tgz - name: Upload tgz artifacts @@ -289,12 +290,16 @@ jobs: # Place vp binary where install-global-cli.ts expects it (target/release/) mkdir -p target/release cp tmp/tgz/vp target/release/vp 2>/dev/null || cp tmp/tgz/vp.exe target/release/vp.exe 2>/dev/null || true + cp tmp/tgz/vp-shim.exe target/release/vp-shim.exe 2>/dev/null || true chmod +x target/release/vp 2>/dev/null || true node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-0.0.0.tgz - echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH + # Use USERPROFILE (native Windows path) instead of HOME (Git Bash path /c/Users/...) + # so cmd.exe and Node.js execSync can resolve binaries in PATH + echo "${USERPROFILE:-$HOME}/.vite-plus/bin" >> $GITHUB_PATH - name: Migrate in ${{ matrix.project.name }} working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} + shell: bash run: | node $GITHUB_WORKSPACE/ecosystem-ci/patch-project.ts ${{ matrix.project.name }} vp install --no-frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13544a624a..e28d4a0fca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,6 +124,7 @@ jobs: path: | ./target/${{ matrix.settings.target }}/release/vp ./target/${{ matrix.settings.target }}/release/vp.exe + ./target/${{ matrix.settings.target }}/release/vp-shim.exe if-no-files-found: error - name: Remove .node files before upload dist diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 0ce0c9a202..60765245b3 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -19,7 +19,7 @@ defaults: shell: bash env: - VITE_PLUS_VERSION: latest + VITE_PLUS_VERSION: alpha jobs: test-install-sh: @@ -131,7 +131,7 @@ jobs: run: | docker run --rm --platform linux/arm64 \ -v "${{ github.workspace }}:/workspace" \ - -e VITE_PLUS_VERSION=latest \ + -e VITE_PLUS_VERSION=alpha \ ubuntu:20.04 bash -c " ls -al ~/ apt-get update && apt-get install -y curl ca-certificates @@ -233,7 +233,7 @@ jobs: exit 1 } - $expectedShims = @("node.cmd", "npm.cmd", "npx.cmd") + $expectedShims = @("node.exe", "npm.exe", "npx.exe") foreach ($shim in $expectedShims) { $shimFile = Join-Path $binPath $shim if (-not (Test-Path $shimFile)) { @@ -300,7 +300,7 @@ jobs: exit 1 } - $expectedShims = @("node.cmd", "npm.cmd", "npx.cmd") + $expectedShims = @("node.exe", "npm.exe", "npx.exe") foreach ($shim in $expectedShims) { $shimFile = Join-Path $binPath $shim if (-not (Test-Path $shimFile)) { @@ -380,8 +380,8 @@ jobs: exit 1 } - # Verify shim executables exist (all use .cmd wrappers on Windows) - $expectedShims = @("node.cmd", "npm.cmd", "npx.cmd") + # Verify shim executables exist (trampoline .exe files on Windows) + $expectedShims = @("node.exe", "npm.exe", "npx.exe") foreach ($shim in $expectedShims) { $shimFile = Join-Path $binPath $shim if (-not (Test-Path $shimFile)) { @@ -419,8 +419,8 @@ jobs: set "BIN_PATH=%USERPROFILE%\.vite-plus\bin" dir "%BIN_PATH%" - REM Verify shim executables exist (Windows uses .cmd wrappers) - for %%s in (node.cmd npm.cmd npx.cmd vp.cmd) do ( + REM Verify shim executables exist (Windows uses trampoline .exe files) + for %%s in (node.exe npm.exe npx.exe vp.exe) do ( if not exist "%BIN_PATH%\%%s" ( echo Error: Shim not found: %BIN_PATH%\%%s exit /b 1 @@ -462,22 +462,13 @@ jobs: exit 1 fi - # Verify .cmd wrappers exist (for cmd.exe/PowerShell) - for shim in node.cmd npm.cmd npx.cmd vp.cmd; do + # Verify trampoline .exe files exist + for shim in node.exe npm.exe npx.exe vp.exe; do if [ ! -f "$BIN_PATH/$shim" ]; then - echo "Error: .cmd wrapper not found: $BIN_PATH/$shim" + echo "Error: Trampoline shim not found: $BIN_PATH/$shim" exit 1 fi - echo "Found .cmd wrapper: $BIN_PATH/$shim" - done - - # Verify shell scripts exist (for Git Bash) - for shim in node npm npx vp; do - if [ ! -f "$BIN_PATH/$shim" ]; then - echo "Error: Shell script not found: $BIN_PATH/$shim" - exit 1 - fi - echo "Found shell script: $BIN_PATH/$shim" + echo "Found trampoline shim: $BIN_PATH/$shim" done # Verify vp env doctor works diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/Cargo.lock b/Cargo.lock index 9c3b9e37ef..de3eccd83d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7529,6 +7529,10 @@ dependencies = [ "which", ] +[[package]] +name = "vite_trampoline" +version = "0.0.0" + [[package]] name = "vite_workspace" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 48ce30a032..3f6aaaccc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -318,3 +318,8 @@ codegen-units = 1 strip = "symbols" # set to `false` for debug information debug = false # set to `true` for debug information panic = "abort" # Let it crash and force ourselves to write safe Rust. + +# The trampoline binary is copied per shim tool (~5-10 copies), so optimize for +# size instead of speed. This reduces it from ~200KB to ~100KB on Windows. +[profile.release.package.vite_trampoline] +opt-level = "z" diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs index 4f15b3c75c..b96d06579a 100644 --- a/crates/vite_global_cli/src/commands/env/doctor.rs +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -239,8 +239,8 @@ async fn check_bin_dir() -> bool { fn shim_filename(tool: &str) -> String { #[cfg(windows)] { - // All tools use .cmd wrappers on Windows (including node) - format!("{tool}.cmd") + // All tools use trampoline .exe files on Windows + format!("{tool}.exe") } #[cfg(not(windows))] @@ -739,10 +739,10 @@ mod tests { #[cfg(windows)] { - // All shims should use .cmd on Windows (matching setup.rs) - assert_eq!(node, "node.cmd"); - assert_eq!(npm, "npm.cmd"); - assert_eq!(npx, "npx.cmd"); + // All shims should use .exe on Windows (trampoline executables) + assert_eq!(node, "node.exe"); + assert_eq!(npm, "npm.exe"); + assert_eq!(npx, "npx.exe"); } #[cfg(not(windows))] diff --git a/crates/vite_global_cli/src/commands/env/global_install.rs b/crates/vite_global_cli/src/commands/env/global_install.rs index 563abf4420..963c1f06fc 100644 --- a/crates/vite_global_cli/src/commands/env/global_install.rs +++ b/crates/vite_global_cli/src/commands/env/global_install.rs @@ -368,7 +368,7 @@ pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"]; /// Create a shim for a package binary. /// /// On Unix: Creates a symlink to ../current/bin/vp -/// On Windows: Creates a .cmd wrapper that calls `vp env exec ` +/// On Windows: Creates a trampoline .exe that forwards to vp.exe async fn create_package_shim( bin_dir: &vite_path::AbsolutePath, bin_name: &str, @@ -406,40 +406,25 @@ async fn create_package_shim( #[cfg(windows)] { - let cmd_path = bin_dir.join(format!("{}.cmd", bin_name)); + let shim_path = bin_dir.join(format!("{}.exe", bin_name)); // Skip if already exists (e.g., re-installing the same package) - if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) { + if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { return Ok(()); } - // Create .cmd wrapper that calls vp env exec . - // Use `--` so args like `--help` are forwarded to the package binary, - // not consumed by clap while parsing `vp env exec`. - // Set VITE_PLUS_HOME using %~dp0.. which resolves to the parent of bin/ - // This ensures the vp binary knows its home directory - let wrapper_content = format!( - "@echo off\r\nset VITE_PLUS_HOME=%~dp0..\r\nset VITE_PLUS_SHIM_WRAPPER=1\r\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" env exec {} -- %*\r\nexit /b %ERRORLEVEL%\r\n", - bin_name - ); - tokio::fs::write(&cmd_path, wrapper_content).await?; - - // Also create shell script for Git Bash (bin_name without extension) - // Uses explicit "vp env exec " instead of symlink+argv[0] because - // Windows symlinks require admin privileges - let sh_path = bin_dir.join(bin_name); - let sh_content = format!( - r#"#!/bin/sh -VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" -export VITE_PLUS_HOME -export VITE_PLUS_SHIM_WRAPPER=1 -exec "$VITE_PLUS_HOME/current/bin/vp.exe" env exec {} -- "$@" -"#, - bin_name - ); - tokio::fs::write(&sh_path, sh_content).await?; + // Copy the trampoline binary as .exe. + // The trampoline detects the tool name from its own filename and sets + // VITE_PLUS_SHIM_TOOL env var before spawning vp.exe. + let trampoline_src = super::setup::get_trampoline_path()?; + tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?; + + // Remove legacy .cmd and shell script wrappers from previous versions. + // In Git Bash/MSYS, the extensionless script takes precedence over .exe, + // so leftover wrappers would bypass the trampoline. + super::setup::cleanup_legacy_windows_shim(bin_dir, bin_name).await; - tracing::debug!("Created package shim wrappers for {} (.cmd and shell script)", bin_name); + tracing::debug!("Created package trampoline shim {:?}", shim_path); } Ok(()) @@ -466,16 +451,15 @@ async fn remove_package_shim( #[cfg(windows)] { - // Remove .cmd wrapper - let cmd_path = bin_dir.join(format!("{}.cmd", bin_name)); - if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) { - tokio::fs::remove_file(&cmd_path).await?; - } - - // Also remove shell script (for Git Bash) - let sh_path = bin_dir.join(bin_name); - if tokio::fs::try_exists(&sh_path).await.unwrap_or(false) { - tokio::fs::remove_file(&sh_path).await?; + // Remove trampoline .exe shim and legacy .cmd / shell script wrappers. + // Best-effort: ignore NotFound errors for files that don't exist. + for suffix in &[".exe", ".cmd", ""] { + let path = if suffix.is_empty() { + bin_dir.join(bin_name) + } else { + bin_dir.join(format!("{bin_name}{suffix}")) + }; + let _ = tokio::fs::remove_file(&path).await; } } @@ -486,13 +470,42 @@ async fn remove_package_shim( mod tests { use super::*; + /// RAII guard that sets `VITE_PLUS_TRAMPOLINE_PATH` to a fake binary on creation + /// and clears it on drop. Ensures cleanup even on test panics. + #[cfg(windows)] + struct FakeTrampolineGuard; + + #[cfg(windows)] + impl FakeTrampolineGuard { + fn new(dir: &std::path::Path) -> Self { + let trampoline = dir.join("vp-shim.exe"); + std::fs::write(&trampoline, b"fake-trampoline").unwrap(); + unsafe { + std::env::set_var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH, &trampoline); + } + Self + } + } + + #[cfg(windows)] + impl Drop for FakeTrampolineGuard { + fn drop(&mut self) { + unsafe { + std::env::remove_var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH); + } + } + } + #[tokio::test] + #[cfg_attr(windows, serial_test::serial)] async fn test_create_package_shim_creates_bin_dir() { use tempfile::TempDir; use vite_path::AbsolutePathBuf; // Create a temp directory but don't create the bin subdirectory let temp_dir = TempDir::new().unwrap(); + #[cfg(windows)] + let _guard = FakeTrampolineGuard::new(temp_dir.path()); let bin_dir = temp_dir.path().join("bin"); let bin_dir = AbsolutePathBuf::new(bin_dir).unwrap(); @@ -505,7 +518,7 @@ mod tests { // Verify bin directory was created assert!(bin_dir.as_path().exists()); - // Verify shim file was created (on Windows, shims have .cmd extension) + // Verify shim file was created (on Windows, shims have .exe extension) // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata #[cfg(unix)] { @@ -517,7 +530,7 @@ mod tests { } #[cfg(windows)] { - let shim_path = bin_dir.join("test-shim.cmd"); + let shim_path = bin_dir.join("test-shim.exe"); assert!(shim_path.as_path().exists()); } } @@ -537,16 +550,19 @@ mod tests { #[cfg(unix)] let shim_path = bin_dir.join("node"); #[cfg(windows)] - let shim_path = bin_dir.join("node.cmd"); + let shim_path = bin_dir.join("node.exe"); assert!(!shim_path.as_path().exists()); } #[tokio::test] + #[cfg_attr(windows, serial_test::serial)] async fn test_remove_package_shim_removes_shim() { use tempfile::TempDir; use vite_path::AbsolutePathBuf; let temp_dir = TempDir::new().unwrap(); + #[cfg(windows)] + let _guard = FakeTrampolineGuard::new(temp_dir.path()); let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); // Create a shim @@ -573,7 +589,7 @@ mod tests { } #[cfg(windows)] { - let shim_path = bin_dir.join("tsc.cmd"); + let shim_path = bin_dir.join("tsc.exe"); assert!(shim_path.as_path().exists(), "Shim should exist after creation"); // Remove the shim @@ -597,13 +613,16 @@ mod tests { } #[tokio::test] + #[cfg_attr(windows, serial_test::serial)] async fn test_uninstall_removes_shims_from_metadata() { use tempfile::TempDir; use vite_path::AbsolutePathBuf; let temp_dir = TempDir::new().unwrap(); let temp_path = temp_dir.path().to_path_buf(); - let _guard = vite_shared::EnvConfig::test_guard( + #[cfg(windows)] + let _trampoline_guard = FakeTrampolineGuard::new(&temp_path); + let _env_guard = vite_shared::EnvConfig::test_guard( vite_shared::EnvConfig::for_test_with_home(&temp_path), ); @@ -630,10 +649,10 @@ mod tests { } #[cfg(windows)] { - assert!(bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should exist"); + assert!(bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should exist"); assert!( - bin_dir.join("tsserver.cmd").as_path().exists(), - "tsserver.cmd shim should exist" + bin_dir.join("tsserver.exe").as_path().exists(), + "tsserver.exe shim should exist" ); } @@ -674,10 +693,10 @@ mod tests { } #[cfg(windows)] { - assert!(!bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should be removed"); + assert!(!bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should be removed"); assert!( - !bin_dir.join("tsserver.cmd").as_path().exists(), - "tsserver.cmd shim should be removed" + !bin_dir.join("tsserver.exe").as_path().exists(), + "tsserver.exe shim should be removed" ); } } diff --git a/crates/vite_global_cli/src/commands/env/setup.rs b/crates/vite_global_cli/src/commands/env/setup.rs index 29c784e55c..6ade827f6d 100644 --- a/crates/vite_global_cli/src/commands/env/setup.rs +++ b/crates/vite_global_cli/src/commands/env/setup.rs @@ -10,8 +10,10 @@ //! - Symlinks preserve argv[0], allowing tool detection via the symlink name //! //! On Windows: -//! - bin/vp.cmd is a wrapper script that calls ..\current\bin\vp.exe -//! - bin/node.cmd, bin/npm.cmd, bin/npx.cmd are wrappers calling `vp env exec ` +//! - bin/vp.exe, bin/node.exe, bin/npm.exe, bin/npx.exe are trampoline executables +//! - Each trampoline detects its tool name from its own filename and spawns +//! current\bin\vp.exe with VITE_PLUS_SHIM_TOOL env var set +//! - This avoids the "Terminate batch job (Y/N)?" prompt from .cmd wrappers use std::process::ExitStatus; @@ -77,6 +79,12 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result } } + // Best-effort cleanup of .old files from rename-before-copy on Windows + #[cfg(windows)] + if refresh { + cleanup_old_files(&bin_dir).await; + } + // Print results if !created.is_empty() { println!("{}", help::render_heading("Created Shims")); @@ -129,35 +137,27 @@ async fn setup_vp_wrapper(bin_dir: &vite_path::AbsolutePath, refresh: bool) -> R #[cfg(windows)] { - let bin_vp_cmd = bin_dir.join("vp.cmd"); - - // Create wrapper script bin/vp.cmd that calls current\bin\vp.exe - let should_create_wrapper = - refresh || !tokio::fs::try_exists(&bin_vp_cmd).await.unwrap_or(false); - - if should_create_wrapper { - // Set VITE_PLUS_HOME using a for loop to canonicalize the path. - // %~dp0.. would produce paths like C:\Users\x\.vite-plus\bin\.. - // The for loop resolves this to a clean C:\Users\x\.vite-plus - let cmd_content = "@echo off\r\nfor %%I in (\"%~dp0..\") do set VITE_PLUS_HOME=%%~fI\r\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" %*\r\nexit /b %ERRORLEVEL%\r\n"; - tokio::fs::write(&bin_vp_cmd, cmd_content).await?; - tracing::debug!("Created wrapper script {:?}", bin_vp_cmd); - } + let bin_vp_exe = bin_dir.join("vp.exe"); + + // Create trampoline bin/vp.exe that forwards to current\bin\vp.exe + let should_create = refresh || !tokio::fs::try_exists(&bin_vp_exe).await.unwrap_or(false); + + if should_create { + let trampoline_src = get_trampoline_path()?; + // On refresh, the existing vp.exe may still be running (the trampoline + // that launched us). Windows prevents overwriting a running exe, so we + // rename it to a timestamped .old file first, then copy the new one. + if tokio::fs::try_exists(&bin_vp_exe).await.unwrap_or(false) { + rename_to_old(&bin_vp_exe).await; + } - // Also create shell script for Git Bash (vp without extension) - // Note: We call vp.exe directly, not via symlink, because Windows - // symlinks require admin privileges and Git Bash support is unreliable - let bin_vp = bin_dir.join("vp"); - let should_create_sh = refresh || !tokio::fs::try_exists(&bin_vp).await.unwrap_or(false); + tokio::fs::copy(trampoline_src.as_path(), &bin_vp_exe).await?; + tracing::debug!("Created trampoline {:?}", bin_vp_exe); + } - if should_create_sh { - let sh_content = r#"#!/bin/sh -VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" -export VITE_PLUS_HOME -exec "$VITE_PLUS_HOME/current/bin/vp.exe" "$@" -"#; - tokio::fs::write(&bin_vp, sh_content).await?; - tracing::debug!("Created shell wrapper script {:?}", bin_vp); + // Clean up legacy .cmd and shell script wrappers from previous versions + if refresh { + cleanup_legacy_windows_shim(bin_dir, "vp").await; } } @@ -189,8 +189,15 @@ async fn create_shim( if !refresh { return Ok(false); } - // Remove existing shim for refresh - tokio::fs::remove_file(&shim_path).await?; + // Remove existing shim for refresh. + // On Windows, .exe files may be locked (by antivirus, indexer, or + // still-running processes), so rename to .old first instead of deleting. + #[cfg(windows)] + rename_to_old(&shim_path).await; + #[cfg(not(windows))] + { + tokio::fs::remove_file(&shim_path).await?; + } } #[cfg(unix)] @@ -210,8 +217,8 @@ async fn create_shim( fn shim_filename(tool: &str) -> String { #[cfg(windows)] { - // All tools use .cmd wrappers on Windows (including node) - format!("{tool}.cmd") + // All tools use trampoline .exe files on Windows + format!("{tool}.exe") } #[cfg(not(windows))] @@ -237,50 +244,129 @@ async fn create_unix_shim( Ok(()) } -/// Create Windows shims using .cmd wrappers that call `vp env exec `. +/// Create Windows shims using trampoline `.exe` files. /// -/// All tools (node, npm, npx, vpx) get .cmd wrappers that invoke `vp env exec`. -/// Also creates shell scripts (without extension) for Git Bash compatibility. -/// This is consistent with Volta's Windows approach. +/// Each tool gets a copy of the trampoline binary renamed to `.exe`. +/// The trampoline detects its tool name from its own filename and spawns +/// vp.exe with `VITE_PLUS_SHIM_TOOL` set, avoiding the "Terminate batch job?" +/// prompt that `.cmd` wrappers cause on Ctrl+C. +/// +/// See: #[cfg(windows)] async fn create_windows_shim( _source: &std::path::Path, bin_dir: &vite_path::AbsolutePath, tool: &str, ) -> Result<(), Error> { - let cmd_path = bin_dir.join(format!("{tool}.cmd")); + let trampoline_src = get_trampoline_path()?; + let shim_path = bin_dir.join(format!("{tool}.exe")); + tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?; - // Create .cmd wrapper that calls vp env exec . - // Use `--` so tool args like `--help` are forwarded to the tool, - // not consumed by clap while parsing `vp env exec`. - // Use a for loop to canonicalize VITE_PLUS_HOME path. - // %~dp0.. would produce paths like C:\Users\x\.vite-plus\bin\.. - // The for loop resolves this to a clean C:\Users\x\.vite-plus - let cmd_content = format!( - "@echo off\r\nfor %%I in (\"%~dp0..\") do set VITE_PLUS_HOME=%%~fI\r\nset VITE_PLUS_SHIM_WRAPPER=1\r\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" env exec {} -- %*\r\nexit /b %ERRORLEVEL%\r\n", - tool - ); + // Clean up legacy .cmd and shell script wrappers from previous versions + cleanup_legacy_windows_shim(bin_dir, tool).await; - tokio::fs::write(&cmd_path, cmd_content).await?; + tracing::debug!("Created trampoline shim {:?}", shim_path); - // Also create shell script for Git Bash (tool without extension) - // Uses explicit "vp env exec " instead of symlink+argv[0] because - // Windows symlinks require admin privileges - let sh_path = bin_dir.join(tool); - let sh_content = format!( - r#"#!/bin/sh -VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" -export VITE_PLUS_HOME -export VITE_PLUS_SHIM_WRAPPER=1 -exec "$VITE_PLUS_HOME/current/bin/vp.exe" env exec {} -- "$@" -"#, - tool - ); - tokio::fs::write(&sh_path, sh_content).await?; + Ok(()) +} - tracing::debug!("Created Windows wrappers for {} (.cmd and shell script)", tool); +/// Get the path to the trampoline template binary (vp-shim.exe). +/// +/// The trampoline binary is distributed alongside vp.exe in the same directory. +/// In tests, `VITE_PLUS_TRAMPOLINE_PATH` can override the resolved path. +#[cfg(windows)] +pub(crate) fn get_trampoline_path() -> Result { + // Allow tests to override the trampoline path + if let Ok(override_path) = std::env::var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH) { + let path = std::path::PathBuf::from(override_path); + if path.exists() { + return vite_path::AbsolutePathBuf::new(path) + .ok_or_else(|| Error::ConfigError("Invalid trampoline override path".into())); + } + } - Ok(()) + let current_exe = std::env::current_exe() + .map_err(|e| Error::ConfigError(format!("Cannot find current executable: {e}").into()))?; + let bin_dir = current_exe + .parent() + .ok_or_else(|| Error::ConfigError("Cannot find parent directory of vp.exe".into()))?; + let trampoline = bin_dir.join("vp-shim.exe"); + + if !trampoline.exists() { + return Err(Error::ConfigError( + format!( + "Trampoline binary not found at {}. Re-install vite-plus to fix this.", + trampoline.display() + ) + .into(), + )); + } + + vite_path::AbsolutePathBuf::new(trampoline) + .ok_or_else(|| Error::ConfigError("Invalid trampoline path".into())) +} + +/// Rename an existing `.exe` to a timestamped `.old` file instead of deleting. +/// +/// On Windows, running `.exe` files can't be deleted or overwritten, but they can +/// be renamed. The `.old` files are cleaned up by `cleanup_old_files()`. +#[cfg(windows)] +async fn rename_to_old(path: &vite_path::AbsolutePath) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if let Some(name) = path.as_path().file_name().and_then(|n| n.to_str()) { + let old_name = format!("{name}.{timestamp}.old"); + let old_path = path.as_path().with_file_name(&old_name); + if let Err(e) = tokio::fs::rename(path, &old_path).await { + tracing::warn!("Failed to rename {} to {}: {}", name, old_name, e); + } + } +} + +/// Best-effort cleanup of accumulated `.old` files from previous rename-before-copy operations. +/// +/// When refreshing `bin/vp.exe` on Windows, the running trampoline is renamed to a +/// timestamped `.old` file. This function tries to delete all such files. Files still +/// in use by a running process will silently fail to delete and be cleaned up next time. +#[cfg(windows)] +async fn cleanup_old_files(bin_dir: &vite_path::AbsolutePath) { + let Ok(mut entries) = tokio::fs::read_dir(bin_dir).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + if name.ends_with(".old") { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } +} + +/// Remove legacy `.cmd` and shell script wrappers from previous versions. +#[cfg(windows)] +pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vite_path::AbsolutePath, tool: &str) { + // Remove old .cmd wrapper (best-effort, ignore NotFound) + let cmd_path = bin_dir.join(format!("{tool}.cmd")); + let _ = tokio::fs::remove_file(&cmd_path).await; + + // Remove old shell script wrapper (extensionless, for Git Bash) + // Only remove if it starts with #!/bin/sh (not a binary or other file) + // Read only the first 9 bytes to avoid loading large files into memory + let sh_path = bin_dir.join(tool); + let is_shell_script = async { + use tokio::io::AsyncReadExt; + let mut file = tokio::fs::File::open(&sh_path).await.ok()?; + let mut buf = [0u8; 9]; // b"#!/bin/sh".len() + let n = file.read(&mut buf).await.ok()?; + Some(buf[..n].starts_with(b"#!/bin/sh")) + // file handle dropped here before remove_file + } + .await; + if is_shell_script == Some(true) { + let _ = tokio::fs::remove_file(&sh_path).await; + } } /// Create env files with PATH guard (prevents duplicate PATH entries). diff --git a/crates/vite_global_cli/src/commands/upgrade/install.rs b/crates/vite_global_cli/src/commands/upgrade/install.rs index f87226014f..29e38375e2 100644 --- a/crates/vite_global_cli/src/commands/upgrade/install.rs +++ b/crates/vite_global_cli/src/commands/upgrade/install.rs @@ -30,6 +30,7 @@ fn is_safe_tar_path(path: &Path) -> bool { /// /// From the platform tarball, extracts: /// - The `vp` binary → `{version_dir}/bin/vp` +/// - The `vp-shim.exe` trampoline → `{version_dir}/bin/vp-shim.exe` (Windows only) /// /// `.node` files are no longer extracted here — npm installs them /// via the platform package's optionalDependencies. @@ -62,7 +63,7 @@ pub async fn extract_platform_package( let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if file_name == "vp" || file_name == "vp.exe" { + if file_name == "vp" || file_name == "vp.exe" || file_name == "vp-shim.exe" { // Binary goes to bin/ let target = bin_dir_clone.join(file_name); let mut buf = Vec::new(); diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 94685f7e84..21a39a64a7 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -261,8 +261,19 @@ fn check_npm_global_install_result( } // Check if binary already exists in bin_dir (vite-plus bin) + // On Unix: symlinks (bin/tsc) + // On Windows: trampoline .exe (bin/tsc.exe) or legacy .cmd (bin/tsc.cmd) let shim_path = bin_dir.join(&bin_name); - if std::fs::symlink_metadata(shim_path.as_path()).is_ok() { + let shim_exists = std::fs::symlink_metadata(shim_path.as_path()).is_ok() || { + #[cfg(windows)] + { + let exe_path = bin_dir.join(vite_str::format!("{bin_name}.exe")); + std::fs::symlink_metadata(exe_path.as_path()).is_ok() + } + #[cfg(not(windows))] + false + }; + if shim_exists { if let Ok(Some(config)) = BinConfig::load_sync(&bin_name) { if config.source == BinSource::Vp { // Managed by vp install -g — warn about the conflict @@ -430,7 +441,9 @@ fn create_bin_link( #[cfg(windows)] { - // Create .cmd wrapper + // npm-installed packages use .cmd wrappers pointing to npm's generated script. + // Unlike vp-installed packages, these don't have PackageMetadata, so the + // trampoline approach won't work (dispatch_package_binary would fail). let cmd_path = bin_dir.join(vite_str::format!("{bin_name}.cmd")); let wrapper_content = vite_str::format!( "@echo off\r\n\"{source}\" %*\r\nexit /b %ERRORLEVEL%\r\n", @@ -523,13 +536,13 @@ fn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefi // Clean up the BinConfig let _ = BinConfig::delete_sync(bin_name); - // Also remove .cmd on Windows + // Also remove .cmd and .exe on Windows #[cfg(windows)] { let cmd_path = bin_dir.join(vite_str::format!("{bin_name}.cmd")); - if cmd_path.as_path().exists() { - let _ = std::fs::remove_file(cmd_path.as_path()); - } + let _ = std::fs::remove_file(cmd_path.as_path()); + let exe_path = bin_dir.join(vite_str::format!("{bin_name}.exe")); + let _ = std::fs::remove_file(exe_path.as_path()); } } else { // Owned by a different npm package — check if our link target is now broken diff --git a/crates/vite_global_cli/src/shim/mod.rs b/crates/vite_global_cli/src/shim/mod.rs index 6f658a1d2f..1b11304f04 100644 --- a/crates/vite_global_cli/src/shim/mod.rs +++ b/crates/vite_global_cli/src/shim/mod.rs @@ -5,8 +5,8 @@ //! //! Detection methods: //! - Unix: Symlinks to vp binary preserve argv[0], allowing tool detection -//! - Windows: .cmd wrappers call `vp env exec ` directly -//! - Legacy: VITE_PLUS_SHIM_TOOL env var (kept for backward compatibility) +//! - Windows: Trampoline `.exe` files set `VITE_PLUS_SHIM_TOOL` env var and spawn vp.exe +//! - Legacy: `.cmd` wrappers call `vp env exec ` directly (deprecated) mod cache; pub(crate) mod dispatch; @@ -77,10 +77,24 @@ fn is_potential_package_binary(tool: &str) -> bool { return false; }; - // Check if the shim exists in the configured bin directory - // Use symlink_metadata to detect symlinks (even broken ones) + // Check if the shim exists in the configured bin directory. + // Use symlink_metadata to detect symlinks (even broken ones). + // On Windows, check .exe first (trampoline shims, the common case), + // then fall back to extensionless (Unix symlinks or legacy). + #[cfg(windows)] + { + let exe_path = configured_bin.join(format!("{tool}.exe")); + if std::fs::symlink_metadata(&exe_path).is_ok() { + return true; + } + } + let shim_path = configured_bin.join(tool); - std::fs::symlink_metadata(&shim_path).is_ok() + if std::fs::symlink_metadata(&shim_path).is_ok() { + return true; + } + + false } /// Environment variable used for shim tool detection via shell wrapper scripts. @@ -89,12 +103,10 @@ const SHIM_TOOL_ENV_VAR: &str = env_vars::VITE_PLUS_SHIM_TOOL; /// Detect the shim tool from environment and argv. /// /// Detection priority: -/// 1. If argv[0] is "vp" or "vp.exe", this is a direct CLI invocation - NOT shim mode -/// 2. Check `VITE_PLUS_SHIM_TOOL` env var (for shell wrapper scripts) +/// 1. Check `VITE_PLUS_SHIM_TOOL` env var (set by trampoline exe on Windows) +/// 2. If argv[0] is "vp" or "vp.exe", this is a direct CLI invocation - NOT shim mode /// 3. Fall back to argv[0] detection (primary method on Unix with symlinks) /// -/// Note: Modern Windows wrappers use `vp env exec ` instead of env vars. -/// /// IMPORTANT: This function clears `VITE_PLUS_SHIM_TOOL` after reading it to /// prevent the env var from leaking to child processes. pub fn detect_shim_tool(argv0: &str) -> Option { @@ -106,17 +118,9 @@ pub fn detect_shim_tool(argv0: &str) -> Option { std::env::remove_var(SHIM_TOOL_ENV_VAR); } - // If argv[0] is explicitly "vp" or "vp.exe", this is a direct CLI invocation. - // Do NOT use the env var in this case - it may be stale from a parent process. - let argv0_tool = extract_tool_name(argv0); - if argv0_tool == "vp" { - return None; // Direct vp invocation, not shim mode - } - if argv0_tool == "vpx" { - return Some("vpx".to_string()); - } - - // Check VITE_PLUS_SHIM_TOOL env var (set by shell wrapper scripts) + // Check VITE_PLUS_SHIM_TOOL env var first (set by trampoline exe on Windows). + // This takes priority over argv[0] because the trampoline spawns vp.exe + // (so argv[0] would be "vp"), but the env var carries the real tool name. if let Some(tool) = env_tool { if !tool.is_empty() { let tool_lower = tool.to_lowercase(); @@ -127,7 +131,16 @@ pub fn detect_shim_tool(argv0: &str) -> Option { } } - // Fall back to argv[0] detection + // If argv[0] is explicitly "vp" or "vp.exe", this is a direct CLI invocation. + let argv0_tool = extract_tool_name(argv0); + if argv0_tool == "vp" { + return None; // Direct vp invocation, not shim mode + } + if argv0_tool == "vpx" { + return Some("vpx".to_string()); + } + + // Fall back to argv[0] detection (Unix symlinks) if is_shim_tool(&argv0_tool) { Some(argv0_tool) } else { None } } diff --git a/crates/vite_shared/src/env_vars.rs b/crates/vite_shared/src/env_vars.rs index 658d50a485..7b3d35f565 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -72,3 +72,11 @@ pub const VITE_PLUS_CLI_BIN: &str = "VITE_PLUS_CLI_BIN"; /// Global CLI version, passed from Rust binary to JS for --version display. pub const VITE_PLUS_GLOBAL_VERSION: &str = "VITE_PLUS_GLOBAL_VERSION"; + +// ── Testing / Development ─────────────────────────────────────────────── + +/// Override the trampoline binary path for tests. +/// +/// When set, `get_trampoline_path()` uses this path instead of resolving +/// relative to `current_exe()`. Only used in test environments. +pub const VITE_PLUS_TRAMPOLINE_PATH: &str = "VITE_PLUS_TRAMPOLINE_PATH"; diff --git a/crates/vite_trampoline/Cargo.toml b/crates/vite_trampoline/Cargo.toml new file mode 100644 index 0000000000..1b200492f0 --- /dev/null +++ b/crates/vite_trampoline/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "vite_trampoline" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "Minimal Windows trampoline exe for vite-plus shims" + +[[bin]] +name = "vp-shim" +path = "src/main.rs" + +# No dependencies — the single Win32 FFI call (SetConsoleCtrlHandler) is +# declared inline to avoid pulling in the heavy `windows`/`windows-core` crates. + +# Override workspace lints: this is a standalone minimal binary that intentionally +# avoids dependencies on vite_shared, vite_path, vite_str, etc. to keep binary +# size small. It uses std types and macros directly. +[lints.clippy] +disallowed_macros = "allow" +disallowed_types = "allow" +disallowed_methods = "allow" + +# Note: Release profile is defined at workspace root (Cargo.toml). +# The workspace already sets lto="fat", codegen-units=1, strip="symbols", panic="abort". +# For even smaller binaries, consider building this crate separately with opt-level="z". diff --git a/crates/vite_trampoline/src/main.rs b/crates/vite_trampoline/src/main.rs new file mode 100644 index 0000000000..dec34097fc --- /dev/null +++ b/crates/vite_trampoline/src/main.rs @@ -0,0 +1,98 @@ +//! Minimal Windows trampoline for vite-plus shims. +//! +//! This binary is copied and renamed for each shim tool (node.exe, npm.exe, etc.). +//! It detects the tool name from its own filename, then spawns `vp.exe` with the +//! `VITE_PLUS_SHIM_TOOL` environment variable set, allowing `vp.exe` to enter +//! shim dispatch mode. +//! +//! On Ctrl+C, the trampoline ignores the signal (the child process handles it), +//! avoiding the "Terminate batch job (Y/N)?" prompt that `.cmd` wrappers produce. +//! +//! **Size optimization**: This binary avoids `core::fmt` (which adds ~100KB) by +//! never using `format!`, `eprintln!`, `println!`, or `.unwrap()`. All error +//! paths use `process::exit(1)` directly. +//! +//! See: + +use std::{ + env, + process::{self, Command}, +}; + +fn main() { + // 1. Determine tool name from our own executable filename + let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1)); + let tool_name = + exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); + + // 2. Locate vp.exe: /../current/bin/vp.exe + let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); + let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1)); + let vp_exe = vp_home.join("current").join("bin").join("vp.exe"); + + // 3. Install Ctrl+C handler that ignores signals (child will handle them). + // This prevents the "Terminate batch job (Y/N)?" prompt. + #[cfg(windows)] + install_ctrl_handler(); + + // 4. Spawn vp.exe + // - Always set VITE_PLUS_HOME so vp.exe uses the correct home directory + // (matches what the old .cmd wrappers did with %~dp0..) + // - If tool is "vp", run in normal CLI mode (no VITE_PLUS_SHIM_TOOL) + // - Otherwise, set VITE_PLUS_SHIM_TOOL so vp.exe enters shim dispatch + let mut cmd = Command::new(&vp_exe); + cmd.args(env::args_os().skip(1)); + cmd.env("VITE_PLUS_HOME", vp_home); + + if tool_name != "vp" { + cmd.env("VITE_PLUS_SHIM_TOOL", tool_name); + // Clear the recursion marker so nested shim invocations (e.g., npm + // spawning node) get fresh version resolution instead of falling + // through to passthrough mode. The old .cmd wrappers went through + // `vp env exec` which cleared this in exec.rs; the trampoline + // bypasses that path. + // Must match vite_shared::env_vars::VITE_PLUS_TOOL_RECURSION + cmd.env_remove("VITE_PLUS_TOOL_RECURSION"); + } + + // 5. Execute and propagate exit code. + // Use write_all instead of eprintln!/format! to avoid pulling in core::fmt (~100KB). + match cmd.status() { + Ok(s) => process::exit(s.code().unwrap_or(1)), + Err(_) => { + use std::io::Write; + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_all(b"vite-plus: failed to execute "); + let _ = handle.write_all(vp_exe.as_os_str().as_encoded_bytes()); + let _ = handle.write_all(b"\n"); + process::exit(1); + } + } +} + +/// Install a console control handler that ignores Ctrl+C, Ctrl+Break, etc. +/// +/// When Ctrl+C is pressed, Windows sends the event to all processes in the +/// console group. By returning TRUE (1), we tell Windows we handled the event +/// (by ignoring it). The child process also receives the event and can +/// decide how to respond (typically by exiting gracefully). +/// +/// This is the same pattern used by uv-trampoline and Python's distlib launcher. +#[cfg(windows)] +fn install_ctrl_handler() { + // Raw FFI declaration to avoid pulling in the heavy `windows`/`windows-core` crates. + // Signature: https://learn.microsoft.com/en-us/windows/console/setconsolectrlhandler + type HandlerRoutine = unsafe extern "system" fn(ctrl_type: u32) -> i32; + unsafe extern "system" { + fn SetConsoleCtrlHandler(handler: Option, add: i32) -> i32; + } + + unsafe extern "system" fn handler(_ctrl_type: u32) -> i32 { + 1 // TRUE - signal handled (ignored) + } + + unsafe { + SetConsoleCtrlHandler(Some(handler), 1); + } +} diff --git a/package.json b/package.json index 23a4321c29..98018d0aad 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "pnpm -F @voidzero-dev/* -F vite-plus build", - "bootstrap-cli": "pnpm build && cargo build -p vite_global_cli --release && pnpm install-global-cli", + "bootstrap-cli": "pnpm build && cargo build -p vite_global_cli -p vite_trampoline --release && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", "install-global-cli": "tool install-global-cli", "tsgo": "tsgo -b tsconfig.json", @@ -13,8 +13,8 @@ "test": "vp test run && pnpm -r snap-test", "fmt": "vp fmt", "test:unit": "vp test run", - "docs:dev": "pnpm --filter=./docs dev", - "docs:build": "pnpm --filter=./docs build", + "docs:dev": "pnpm -C docs dev", + "docs:build": "pnpm -C docs build", "prepare": "husky" }, "devDependencies": { diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index b1f56462f1..10100ca920 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -183,6 +183,16 @@ function Configure-UserPath { return "true" } +# Run vp env setup --refresh, showing output only on failure +function Refresh-Shims { + param([string]$BinDir) + $setupOutput = & "$BinDir\vp.exe" env setup --refresh 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warn "Failed to refresh shims:" + Write-Host "$setupOutput" + } +} + # Setup Node.js version manager (node/npm/npx shims) # Returns: "true" = enabled, "false" = not enabled, "already" = already configured function Setup-NodeManager { @@ -193,13 +203,13 @@ function Setup-NodeManager { # Check if Vite+ is already managing Node.js (bin\node.exe exists) if (Test-Path "$binPath\node.exe") { # Already managing Node.js, just refresh shims - & "$BinDir\vp.exe" env setup --refresh | Out-Null + Refresh-Shims -BinDir $BinDir return "already" } # Auto-enable on CI environment if ($env:CI) { - & "$BinDir\vp.exe" env setup --refresh | Out-Null + Refresh-Shims -BinDir $BinDir return "true" } @@ -208,7 +218,7 @@ function Setup-NodeManager { # Auto-enable if no node available on system if (-not $nodeAvailable) { - & "$BinDir\vp.exe" env setup --refresh | Out-Null + Refresh-Shims -BinDir $BinDir return "true" } @@ -220,7 +230,7 @@ function Setup-NodeManager { $response = Read-Host "Press Enter to accept (Y/n)" if ($response -eq '' -or $response -eq 'y' -or $response -eq 'Y') { - & "$BinDir\vp.exe" env setup --refresh | Out-Null + Refresh-Shims -BinDir $BinDir return "true" } } @@ -272,6 +282,11 @@ function Main { # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) if ($LocalBinary -and (Test-Path $LocalBinary)) { Copy-Item -Path $LocalBinary -Destination (Join-Path $BinDir $binaryName) -Force + # Also copy trampoline shim binary if available (sibling to vp.exe) + $shimSource = Join-Path (Split-Path $LocalBinary) "vp-shim.exe" + if (Test-Path $shimSource) { + Copy-Item -Path $shimSource -Destination (Join-Path $BinDir "vp-shim.exe") -Force + } } else { Write-Error-Exit "VITE_PLUS_LOCAL_BINARY must be set when using VITE_PLUS_LOCAL_TGZ" } @@ -293,10 +308,16 @@ function Main { & "$env:SystemRoot\System32\tar.exe" -xzf $platformTempFile -C $platformTempExtract # Copy binary to BinDir - $binarySource = Join-Path (Join-Path $platformTempExtract "package") $binaryName + $packageDir = Join-Path $platformTempExtract "package" + $binarySource = Join-Path $packageDir $binaryName if (Test-Path $binarySource) { Copy-Item -Path $binarySource -Destination $BinDir -Force } + # Also copy trampoline shim binary if present in the package + $shimSource = Join-Path $packageDir "vp-shim.exe" + if (Test-Path $shimSource) { + Copy-Item -Path $shimSource -Destination $BinDir -Force + } Remove-Item -Recurse -Force $platformTempExtract } finally { @@ -347,27 +368,46 @@ function Main { # Create new junction pointing to the version directory cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null - # Create bin directory and vp.cmd wrapper (always done) - # Set VITE_PLUS_HOME so the vp binary knows its home directory + # Create bin directory and vp wrapper (always done) New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null - $wrapperContent = @" + $trampolineSrc = "$VersionDir\bin\vp-shim.exe" + if (Test-Path $trampolineSrc) { + # New versions: use trampoline exe to avoid "Terminate batch job (Y/N)?" on Ctrl+C + Copy-Item -Path $trampolineSrc -Destination "$InstallDir\bin\vp.exe" -Force + # Remove legacy .cmd and shell script wrappers from previous versions + foreach ($legacy in @("$InstallDir\bin\vp.cmd", "$InstallDir\bin\vp")) { + if (Test-Path $legacy) { + Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue + } + } + } else { + # Pre-trampoline versions: fall back to legacy .cmd and shell script wrappers. + # Remove any stale trampoline .exe shims left by a newer install — .exe wins + # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers. + foreach ($stale in @("vp.exe", "node.exe", "npm.exe", "npx.exe", "vpx.exe")) { + $stalePath = Join-Path "$InstallDir\bin" $stale + if (Test-Path $stalePath) { + Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue + } + } + # Keep consistent with the original install.ps1 wrapper format + $wrapperContent = @" @echo off set VITE_PLUS_HOME=%~dp0.. "%VITE_PLUS_HOME%\current\bin\vp.exe" %* exit /b %ERRORLEVEL% "@ - Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline + Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline - # Create shell script wrapper for Git Bash (vp without extension) - # Note: We call vp.exe directly (not via symlink) because Windows symlinks - # require admin privileges and Git Bash symlink support is unreliable - $shContent = @" + # Also create shell script wrapper for Git Bash/MSYS + $shContent = @" #!/bin/sh VITE_PLUS_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0")")")" export VITE_PLUS_HOME exec "`$VITE_PLUS_HOME/current/bin/vp.exe" "`$@" "@ - Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + } # Cleanup old versions Cleanup-OldVersions -InstallDir $InstallDir diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 8d4fd05fac..3f7b056254 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -413,6 +413,17 @@ configure_shell_path() { # If result is still 1, PATH_CONFIGURED remains "false" (set at function start) } +# Run vp env setup --refresh, showing output only on failure +# Arguments: vp_bin - path to the vp binary +refresh_shims() { + local vp_bin="$1" + local setup_output + if ! setup_output=$("$vp_bin" env setup --refresh 2>&1); then + warn "Failed to refresh shims:" + echo "$setup_output" >&2 + fi +} + # Setup Node.js version manager (node/npm/npx shims) # Sets NODE_MANAGER_ENABLED global # Arguments: bin_dir - path to the version's bin directory containing vp @@ -421,17 +432,22 @@ setup_node_manager() { local bin_path="$INSTALL_DIR/bin" NODE_MANAGER_ENABLED="false" - # Check if Vite+ is already managing Node.js (bin/node exists) - if [ -e "$bin_path/node" ]; then - # Already managing Node.js, just refresh shims - "$bin_dir/vp" env setup --refresh > /dev/null + # Resolve vp binary name (vp on Unix, vp.exe on Windows) + local vp_bin="$bin_dir/vp" + if [ -f "$bin_dir/vp.exe" ]; then + vp_bin="$bin_dir/vp.exe" + fi + + # Check if Vite+ is already managing Node.js (bin/node or bin/node.exe exists) + if [ -e "$bin_path/node" ] || [ -e "$bin_path/node.exe" ]; then + refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="already" return 0 fi # Auto-enable on CI environment if [ -n "$CI" ]; then - "$bin_dir/vp" env setup --refresh > /dev/null + refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="true" return 0 fi @@ -444,7 +460,7 @@ setup_node_manager() { # Auto-enable if no node available on system if [ "$node_available" = "false" ]; then - "$bin_dir/vp" env setup --refresh > /dev/null + refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="true" return 0 fi @@ -457,7 +473,7 @@ setup_node_manager() { read -r response < /dev/tty if [ -z "$response" ] || [ "$response" = "y" ] || [ "$response" = "Y" ]; then - "$bin_dir/vp" env setup --refresh > /dev/null + refresh_shims "$vp_bin" NODE_MANAGER_ENABLED="true" fi fi @@ -554,6 +570,14 @@ main() { # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) if [ -n "$LOCAL_BINARY" ]; then cp "$LOCAL_BINARY" "$BIN_DIR/$binary_name" + # On Windows, also copy the trampoline shim binary if available + if [[ "$platform" == win32* ]]; then + local shim_src + shim_src="$(dirname "$LOCAL_BINARY")/vp-shim.exe" + if [ -f "$shim_src" ]; then + cp "$shim_src" "$BIN_DIR/vp-shim.exe" + fi + fi else error "VITE_PLUS_LOCAL_BINARY must be set when using VITE_PLUS_LOCAL_TGZ" fi @@ -572,6 +596,10 @@ main() { # Copy binary to BIN_DIR cp "$platform_temp_dir/$binary_name" "$BIN_DIR/" chmod +x "$BIN_DIR/$binary_name" + # On Windows, also copy the trampoline shim binary if present in the package + if [[ "$platform" == win32* ]] && [ -f "$platform_temp_dir/vp-shim.exe" ]; then + cp "$platform_temp_dir/vp-shim.exe" "$BIN_DIR/" + fi rm -rf "$platform_temp_dir" fi @@ -600,7 +628,11 @@ NPMRC_EOF # e.g. during local dev where install-global-cli.ts handles deps separately) if [ -z "${VITE_PLUS_SKIP_DEPS_INSTALL:-}" ]; then local install_log="$VERSION_DIR/install.log" - if ! (cd "$VERSION_DIR" && CI=true "$BIN_DIR/vp" install --silent > "$install_log" 2>&1); then + local vp_install_bin="$BIN_DIR/vp" + if [ -f "$BIN_DIR/vp.exe" ]; then + vp_install_bin="$BIN_DIR/vp.exe" + fi + if ! (cd "$VERSION_DIR" && CI=true "$vp_install_bin" install --silent > "$install_log" 2>&1); then error "Failed to install dependencies. See log for details: $install_log" exit 1 fi @@ -609,15 +641,29 @@ NPMRC_EOF # Create/update current symlink (use relative path for portability) ln -sfn "$VITE_PLUS_VERSION" "$CURRENT_LINK" - # Create bin directory and vp symlink (always done) + # Create bin directory and vp entrypoint (always done) mkdir -p "$INSTALL_DIR/bin" - ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + if [[ "$platform" == win32* ]]; then + # Windows: copy trampoline as vp.exe (matching install.ps1) + if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then + cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$INSTALL_DIR/bin/vp.exe" + fi + else + # Unix: symlink to current/bin/vp + ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + fi # Cleanup old versions cleanup_old_versions # Create env files with PATH guard (prevents duplicate PATH entries) - "$INSTALL_DIR/bin/vp" env setup --env-only > /dev/null + # Use current/bin/vp directly (the real binary) instead of bin/vp (trampoline) + # to avoid the self-overwrite issue on Windows during --refresh + local vp_bin="$INSTALL_DIR/current/bin/vp" + if [[ "$platform" == win32* ]]; then + vp_bin="$INSTALL_DIR/current/bin/vp.exe" + fi + "$vp_bin" env setup --env-only > /dev/null # Configure shell PATH (always attempted) configure_shell_path diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index 470a0e5024..6e1821d759 100644 --- a/packages/cli/publish-native-addons.ts +++ b/packages/cli/publish-native-addons.ts @@ -108,13 +108,30 @@ for (const [platform, rustTarget] of Object.entries(RUST_TARGETS)) { chmodSync(join(platformCliDir, binaryName), 0o755); } + // Copy trampoline shim binary for Windows (required) + // The trampoline is a small exe that replaces .cmd wrappers to avoid + // "Terminate batch job (Y/N)?" on Ctrl+C (see issue #835) + const shimName = 'vp-shim.exe'; + const files = [binaryName]; + if (isWindows) { + const shimSource = join(repoRoot, 'target', rustTarget, 'release', shimName); + if (!existsSync(shimSource)) { + console.error( + `Error: ${shimName} not found at ${shimSource}. Run "cargo build -p vite_trampoline --release --target ${rustTarget}" first.`, + ); + process.exit(1); + } + copyFileSync(shimSource, join(platformCliDir, shimName)); + files.push(shimName); + } + // Generate package.json const cliPackage = { name: `@voidzero-dev/vite-plus-cli-${platform}`, version: cliVersion, os: [meta.os], cpu: [meta.cpu], - files: [binaryName], + files, description: `Vite+ CLI binary for ${platform}`, repository: cliPackageJson.repository, }; diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index 68dd3e4b79..ed381af404 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -89,6 +89,17 @@ export function installGlobalCli() { process.exit(1); } + // On Windows, the trampoline shim binary is required for creating shims. + // Validate it exists beside the chosen vp.exe to avoid mismatched artifacts. + if (isWindows) { + const shimPath = path.join(path.dirname(binaryPath), 'vp-shim.exe'); + if (!existsSync(shimPath)) { + console.error(`Error: vp-shim.exe not found at ${shimPath}`); + console.error('Build it with: cargo build -p vite_trampoline --release'); + process.exit(1); + } + } + const localDevVer = localDevVersion(); // Clean up old local-dev directories to avoid accumulation diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 062495e8cb..6aec2333ac 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -23,7 +23,7 @@ This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe A shim-based approach where: - `VITE_PLUS_HOME/bin/` directory is added to PATH (system-level for IDE reliability) -- Shims (`node`, `npm`, `npx`) are symlinks to the `vp` binary (Unix) or `.cmd` wrappers (Windows) +- Shims (`node`, `npm`, `npx`) are symlinks to the `vp` binary (Unix) or trampoline `.exe` files (Windows) - The `vp` CLI itself is also in `VITE_PLUS_HOME/bin/`, so users only need one PATH entry - The binary detects invocation via `argv[0]` and dispatches accordingly - Version resolution and installation leverage existing `vite_js_runtime` infrastructure @@ -344,16 +344,11 @@ VITE_PLUS_HOME/ # Default: ~/.vite-plus │ ├── npm -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── npx -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── tsc -> ../current/bin/vp # Symlink for global package (Unix) -│ ├── vp # Shell script for Git Bash (Windows) -│ ├── vp.cmd # Wrapper calling ..\current\bin\vp.exe (Windows) -│ ├── node # Shell script for Git Bash (Windows) -│ ├── node.cmd # Wrapper calling vp env exec node (Windows) -│ ├── npm # Shell script for Git Bash (Windows) -│ ├── npm.cmd # Wrapper calling vp env exec npm (Windows) -│ ├── npx # Shell script for Git Bash (Windows) -│ ├── npx.cmd # Wrapper calling vp env exec npx (Windows) -│ ├── tsc # Shell script for global package Git Bash (Windows) -│ └── tsc.cmd # Wrapper for global package (Windows) +│ ├── vp.exe # Trampoline forwarding to current\bin\vp.exe (Windows) +│ ├── node.exe # Trampoline shim for node (Windows) +│ ├── npm.exe # Trampoline shim for npm (Windows) +│ ├── npx.exe # Trampoline shim for npx (Windows) +│ └── tsc.exe # Trampoline shim for global package (Windows) ├── current/ │ └── bin/ │ ├── vp # The actual vp CLI binary (Unix) @@ -734,17 +729,16 @@ fn execute_run_command() { - No binary accumulation issues (symlinks are just filesystem pointers) - Relative symlinks (e.g., `../current/bin/vp`) work within the same directory tree -### 3. Wrapper Scripts for Windows +### 3. Trampoline Executables for Windows -**Decision**: Use `.cmd` wrapper scripts on Windows that call `vp env exec `. +**Decision**: Use lightweight trampoline `.exe` files on Windows instead of `.cmd` wrappers. Each trampoline detects its tool name from its own filename, sets `VITE_PLUS_SHIM_TOOL`, and spawns `vp.exe`. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md). **Rationale**: -- Windows PATH resolution prefers `.cmd` over `.exe` for extensionless commands -- Simple wrapper format: `vp env exec npm %*` - no binary copies needed -- Same pattern as Volta (`volta run `) -- Single `vp.exe` binary to maintain in `current/bin/` -- No `VITE_PLUS_SHIM_TOOL` env var complexity - dispatch via `vp env exec` command +- `.cmd` wrappers cause "Terminate batch job (Y/N)?" prompt on Ctrl+C +- `.exe` files work in all shells (cmd.exe, PowerShell, Git Bash) without needing separate wrappers +- Single trampoline binary (~100-150KB) copied per tool — no `.cmd` + shell script pair needed +- Ctrl+C handled cleanly via `SetConsoleCtrlHandler` ### 4. execve on Unix, spawn on Windows @@ -1853,7 +1847,7 @@ This is useful for: - Testing code against different Node versions - Running one-off commands without changing project configuration - CI/CD scripts that need explicit version control -- Windows shims (`.cmd` wrappers and Git Bash shell scripts call `vp env exec `) +- Legacy Windows `.cmd` wrappers (deprecated in favor of trampoline `.exe` shims) ### Usage @@ -1897,7 +1891,7 @@ When `--node` is **not provided** and the first command is a shim tool: - **Core tools (node, npm, npx)**: Version resolved from `.node-version`, `package.json#engines.node`, or default - **Global packages (tsc, eslint, etc.)**: Uses the Node.js version that was used during `vp install -g` -Both use the **exact same code path** as Unix symlinks (`shim::dispatch()`), ensuring identical behavior across platforms. This is how Windows `.cmd` wrappers and Git Bash shell scripts work. +Both use the **exact same code path** as Unix symlinks (`shim::dispatch()`), ensuring identical behavior across platforms. On Windows, trampoline `.exe` shims set `VITE_PLUS_SHIM_TOOL` to enter shim dispatch mode. **Important**: The `VITE_PLUS_TOOL_RECURSION` environment variable is cleared before dispatch to ensure fresh version resolution, even when invoked from within a context where the variable is already set (e.g., when pnpm runs through the vite-plus shim). @@ -2123,24 +2117,20 @@ ln -sf ../current/bin/vp ~/.vite-plus/bin/tsc ``` VITE_PLUS_HOME\ ├── bin\ -│ ├── vp # Shell script for Git Bash (calls vp.exe directly) -│ ├── vp.cmd # Wrapper for cmd.exe/PowerShell -│ ├── node # Shell script for Git Bash (calls vp env exec node) -│ ├── node.cmd # Wrapper calling vp env exec node -│ ├── npm # Shell script for Git Bash (calls vp env exec npm) -│ ├── npm.cmd # Wrapper calling vp env exec npm -│ ├── npx # Shell script for Git Bash (calls vp env exec npx) -│ ├── npx.cmd # Wrapper calling vp env exec npx -│ ├── tsc # Shell script for global package (Git Bash) -│ └── tsc.cmd # Wrapper for global package (cmd.exe/PowerShell) +│ ├── vp.exe # Trampoline forwarding to current\bin\vp.exe +│ ├── node.exe # Trampoline shim (sets VITE_PLUS_SHIM_TOOL=node) +│ ├── npm.exe # Trampoline shim (sets VITE_PLUS_SHIM_TOOL=npm) +│ ├── npx.exe # Trampoline shim (sets VITE_PLUS_SHIM_TOOL=npx) +│ └── tsc.exe # Trampoline shim for global package └── current\ └── bin\ - └── vp.exe # The actual vp CLI binary + ├── vp.exe # The actual vp CLI binary + └── vp-shim.exe # Trampoline template (copied as shims) ``` -### Shell Scripts for Git Bash +### Trampoline Executables -Git Bash (MSYS2/MinGW) doesn't use Windows' PATHEXT mechanism, so it won't find `.cmd` files when you type a command without extension. Shell script wrappers (without extension) are created alongside all `.cmd` files. +Windows shims use lightweight trampoline `.exe` files (see [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md)). Each trampoline detects its tool name from its own filename, sets `VITE_PLUS_SHIM_TOOL`, and spawns `vp.exe`. This avoids the "Terminate batch job (Y/N)?" prompt from `.cmd` wrappers and works in all shells (cmd.exe, PowerShell, Git Bash) without needing separate wrapper formats. #### Why Not Symlinks? @@ -2148,68 +2138,14 @@ On Unix, shims are symlinks to the vp binary, which preserves argv[0] for tool d 1. **Admin privileges required**: Windows symlinks need admin rights or Developer Mode 2. **Unreliable Git Bash support**: Symlink emulation varies by Git for Windows version -3. **Consistent with .cmd approach**: Both .cmd and shell scripts use the same dispatch pattern -#### Wrapper Scripts - -**vp wrapper** (calls vp.exe directly): - -```sh -#!/bin/sh -VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" -export VITE_PLUS_HOME -exec "$VITE_PLUS_HOME/current/bin/vp.exe" "$@" -``` - -**Tool wrappers** (node, npm, npx - uses explicit dispatch): - -```sh -#!/bin/sh -VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" -export VITE_PLUS_HOME -exec "$VITE_PLUS_HOME/current/bin/vp.exe" env exec node "$@" -``` - -This ensures all commands work in: - -- Git Bash -- WSL (if accessing Windows paths) -- Any POSIX-compatible shell on Windows - -### Wrapper Script Template (vp.cmd) - -```batch -@echo off -set VITE_PLUS_HOME=%~dp0.. -"%VITE_PLUS_HOME%\current\bin\vp.exe" %* -exit /b %ERRORLEVEL% -``` - -The `vp.cmd` wrapper forwards all arguments to the actual `vp.exe` binary. - -### Wrapper Script Template (node.cmd, npm.cmd, npx.cmd) - -```batch -@echo off -set VITE_PLUS_HOME=%~dp0.. -"%VITE_PLUS_HOME%\current\bin\vp.exe" env exec node %* -exit /b %ERRORLEVEL% -``` - -For npm: - -```batch -@echo off -set VITE_PLUS_HOME=%~dp0.. -"%VITE_PLUS_HOME%\current\bin\vp.exe" env exec npm %* -exit /b %ERRORLEVEL% -``` +Instead, trampoline `.exe` files are used. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md) for the full design. **How it works**: 1. User runs `npm install` -2. Windows finds `~/.vite-plus/bin/npm.cmd` in PATH (cmd.exe/PowerShell) or `npm` (Git Bash) -3. Wrapper calls `vp.exe env exec npm install` +2. Windows finds `~/.vite-plus/bin/npm.exe` in PATH +3. Trampoline sets `VITE_PLUS_SHIM_TOOL=npm` and spawns `vp.exe` 4. `vp env exec` command handles version resolution and execution **Benefits of this approach**: @@ -2224,11 +2160,10 @@ exit /b %ERRORLEVEL% The Windows installer (`install.ps1`) follows this flow: -1. Download and install `vp.exe` to `~/.vite-plus/current/bin/` -2. Create `~/.vite-plus/bin/vp.cmd` wrapper script -3. Create `~/.vite-plus/bin/vp` shell script (for Git Bash) -4. Create shim wrappers: `node.cmd`, `npm.cmd`, `npx.cmd` (and corresponding shell scripts) -5. Configure User PATH to include `~/.vite-plus/bin` +1. Download and install `vp.exe` and `vp-shim.exe` to `~/.vite-plus/current/bin/` +2. Create `~/.vite-plus/bin/vp.exe` trampoline (copy of `vp-shim.exe`) +3. Create shim trampolines: `node.exe`, `npm.exe`, `npx.exe` (via `vp env setup`) +4. Configure User PATH to include `~/.vite-plus/bin` ## Testing Strategy @@ -2266,7 +2201,7 @@ env-doctor/ - ubuntu-latest: Full integration tests - macos-latest: Full integration tests -- windows-latest: Full integration tests with .cmd wrapper validation +- windows-latest: Full integration tests with trampoline `.exe` shim validation ## Security Considerations @@ -2282,7 +2217,7 @@ env-doctor/ 1. Add `vp env` command structure to CLI 2. Implement argv[0] detection in main.rs 3. Implement shim dispatch logic for `node` -4. Implement `vp env setup` (Unix symlinks, Windows .cmd wrappers) +4. Implement `vp env setup` (Unix symlinks, Windows trampoline `.exe` shims) 5. Implement `vp env doctor` basic diagnostics 6. Add resolution cache (persists across upgrades with version field) 7. Implement `vp env default [version]` to set/show global default Node.js version @@ -2338,7 +2273,7 @@ The following decisions have been made: 1. **VITE_PLUS_HOME Default Location**: `~/.vite-plus` - Simple, memorable path that's easy for users to find and configure. -2. **Windows Wrapper Strategy**: `.cmd` wrappers that call `vp env exec ` - Consistent with Volta, no binary copies needed. +2. **Windows Shim Strategy**: Trampoline `.exe` files that set `VITE_PLUS_SHIM_TOOL` and spawn `vp.exe` - Avoids "Terminate batch job?" prompt, works in all shells. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md). 3. **Corepack Handling**: Not included - vite-plus has integrated package manager functionality, making corepack shims unnecessary. diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md new file mode 100644 index 0000000000..f6de78b2a4 --- /dev/null +++ b/rfcs/trampoline-exe-for-shims.md @@ -0,0 +1,285 @@ +# RFC: Windows Trampoline `.exe` for Shims + +## Status + +Implemented + +## Summary + +Replace Windows `.cmd` wrapper scripts with lightweight trampoline `.exe` binaries for all shim tools (`vp`, `node`, `npm`, `npx`, `vpx`, and globally installed package binaries). This eliminates the `Terminate batch job (Y/N)?` prompt that appears when users press Ctrl+C, providing the same clean signal behavior as direct `.exe` invocation. + +## Motivation + +### The Problem + +On Windows, the vite-plus CLI previously exposed tools through `.cmd` batch file wrappers: + +``` +~/.vite-plus/bin/ +├── vp.cmd → calls current\bin\vp.exe +├── node.cmd → calls vp.exe env exec node +├── npm.cmd → calls vp.exe env exec npm +├── npx.cmd → calls vp.exe env exec npx +└── ... +``` + +When a user presses Ctrl+C while a command is running through a `.cmd` wrapper, `cmd.exe` intercepts the signal and displays: + +``` +Terminate batch job (Y/N)? +``` + +This is a fundamental limitation of batch file execution on Windows. The prompt: + +- Interrupts the normal Ctrl+C workflow that users expect +- May appear multiple times (once per `.cmd` in the chain) +- Differs from Unix behavior where Ctrl+C cleanly terminates the process +- Cannot be suppressed from within the batch file + +### Confirmed Behavior + +As demonstrated in [issue #835](https://github.com/voidzero-dev/vite-plus/issues/835): + +1. Running `vp dev` (through `vp.cmd`) shows `Terminate batch job (Y/N)?` on Ctrl+C +2. Running `~/.vite-plus/current/bin/vp.exe dev` directly does **NOT** show the prompt +3. Running `npm.cmd run dev` shows the prompt; running `npm.ps1 run dev` does not +4. The prompt can appear multiple times when `.cmd` wrappers chain (e.g., `vp.cmd` → `npm.cmd`) + +### Why `.ps1` Scripts Are Not Sufficient + +PowerShell `.ps1` scripts avoid the Ctrl+C issue but have critical limitations: + +- `where.exe` and `which` do not discover `.ps1` files as executables +- Only work in PowerShell, not in `cmd.exe`, Git Bash, or other shells +- Cannot serve as universal shims + +## Architecture + +### Unix (Symlink-Based — Unchanged) + +On Unix, shims are symlinks to the `vp` binary. The binary detects the tool name from `argv[0]`: + +``` +~/.vite-plus/bin/ +├── vp → ../current/bin/vp (symlink) +├── node → ../current/bin/vp (symlink) +├── npm → ../current/bin/vp (symlink) +└── npx → ../current/bin/vp (symlink) +``` + +### Windows (Trampoline `.exe` Files) + +``` +~/.vite-plus/bin/ +├── vp.exe # Trampoline → spawns current\bin\vp.exe +├── node.exe # Trampoline → sets VITE_PLUS_SHIM_TOOL=node, spawns vp.exe +├── npm.exe # Trampoline → sets VITE_PLUS_SHIM_TOOL=npm, spawns vp.exe +├── npx.exe # Trampoline → sets VITE_PLUS_SHIM_TOOL=npx, spawns vp.exe +├── vpx.exe # Trampoline → sets VITE_PLUS_SHIM_TOOL=vpx, spawns vp.exe +└── tsc.exe # Trampoline → sets VITE_PLUS_SHIM_TOOL=tsc, spawns vp.exe (package shim) +``` + +Each trampoline is a copy of `vp-shim.exe` (the template binary distributed alongside `vp.exe`). + +**Note**: npm-installed packages (via `npm install -g`) still use `.cmd` wrappers because they lack `PackageMetadata` and need to point directly at npm's generated scripts. + +## Implementation + +### Crate Structure + +``` +crates/vite_trampoline/ +├── Cargo.toml # Zero external dependencies +├── src/ +│ └── main.rs # ~90 lines, single-file binary +``` + +### Trampoline Binary + +The trampoline has **zero external dependencies** — the Win32 FFI call (`SetConsoleCtrlHandler`) is declared inline to avoid the heavy `windows`/`windows-core` crates. It also avoids `core::fmt` (~100KB overhead) by never using `format!`, `eprintln!`, `println!`, or `.unwrap()`. + +```rust +use std::{env, process::{self, Command}}; + +fn main() { + // 1. Determine tool name from own filename (e.g., node.exe → "node") + let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1)); + let tool_name = exe_path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_else(|| process::exit(1)); + + // 2. Locate vp.exe at ../current/bin/vp.exe + let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); + let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1)); + let vp_exe = vp_home.join("current").join("bin").join("vp.exe"); + + // 3. Install Ctrl+C handler (ignores signal; child handles it) + install_ctrl_handler(); + + // 4. Spawn vp.exe with env vars + let mut cmd = Command::new(&vp_exe); + cmd.args(env::args_os().skip(1)); + cmd.env("VITE_PLUS_HOME", vp_home); + + if tool_name != "vp" { + cmd.env("VITE_PLUS_SHIM_TOOL", tool_name); + cmd.env_remove("VITE_PLUS_TOOL_RECURSION"); + } + + // 5. Propagate exit code (error message via write_all, not eprintln!) + match cmd.status() { + Ok(s) => process::exit(s.code().unwrap_or(1)), + Err(_) => { + use std::io::Write; + let mut stderr = std::io::stderr().lock(); + let _ = stderr.write_all(b"vite-plus: failed to execute "); + let _ = stderr.write_all(vp_exe.as_os_str().as_encoded_bytes()); + let _ = stderr.write_all(b"\n"); + process::exit(1); + } + } +} + +fn install_ctrl_handler() { + type HandlerRoutine = unsafe extern "system" fn(ctrl_type: u32) -> i32; + unsafe extern "system" { + fn SetConsoleCtrlHandler(handler: Option, add: i32) -> i32; + } + unsafe extern "system" fn handler(_ctrl_type: u32) -> i32 { 1 } + unsafe { SetConsoleCtrlHandler(Some(handler), 1); } +} +``` + +### Size Optimization + +| Technique | Savings | Status | +| ------------------------------------------------------------------------------------- | -------------------------- | ------ | +| Zero external dependencies (raw FFI) | ~20KB (vs `windows` crate) | Done | +| No direct `core::fmt` usage (avoid `eprintln!`/`format!`/`.unwrap()`) | Marginal | Done | +| Workspace profile: `lto="fat"`, `codegen-units=1`, `strip="symbols"`, `panic="abort"` | Inherited | Done | +| Per-package `opt-level="z"` (optimize for size) | ~5-10% | Done | + +**Binary size**: ~200KB on Windows. The floor is set by `std::process::Command` which internally pulls in `core::fmt` for error formatting regardless of whether our code uses it. Further reduction to ~40-50KB (matching uv-trampoline) would require replacing `Command` with raw `CreateProcessW` and using nightly Rust (see Future Optimizations). + +### Environment Variables + +The trampoline sets three env vars before spawning `vp.exe`: + +| Variable | When | Purpose | +| -------------------------- | -------------------------- | ------------------------------------------------------------------------------ | +| `VITE_PLUS_HOME` | Always | Tells vp.exe the install directory (derived from `bin_dir.parent()`) | +| `VITE_PLUS_SHIM_TOOL` | Tool shims only (not "vp") | Tells vp.exe to enter shim dispatch mode for the named tool | +| `VITE_PLUS_TOOL_RECURSION` | Removed for tool shims | Clears the recursion marker for fresh version resolution in nested invocations | + +### Ctrl+C Handling + +The trampoline installs a console control handler that returns `TRUE` (1): + +1. When Ctrl+C is pressed, Windows sends `CTRL_C_EVENT` to **all processes** in the console group +2. The trampoline's handler returns 1 (TRUE) → trampoline stays alive +3. The child process (`vp.exe` → Node.js) receives the **same** event +4. The child decides how to handle it (typically exits gracefully) +5. The trampoline detects the child's exit and propagates its exit code + +**No "Terminate batch job?" prompt** because there is no batch file involved. + +### Integration with Shim Detection + +`detect_shim_tool()` in `shim/mod.rs` checks `VITE_PLUS_SHIM_TOOL` env var **before** `argv[0]`: + +``` +Trampoline (node.exe) + → sets VITE_PLUS_SHIM_TOOL=node, VITE_PLUS_HOME=..., removes VITE_PLUS_TOOL_RECURSION + → spawns current/bin/vp.exe with original args + → detect_shim_tool() reads env var → "node" + → dispatch("node", args) + → resolves Node.js version, executes real node +``` + +### Running Exe Overwrite + +When `vp env setup --refresh` is invoked through the trampoline (`~/.vite-plus/bin/vp.exe`), the trampoline is still running. Windows prevents overwriting a running `.exe`. The solution: + +1. Rename existing `vp.exe` to `vp.exe..old` +2. Copy new trampoline to `vp.exe` +3. Best-effort cleanup of all `*.old` files in the bin directory + +### Distribution + +The trampoline binary (`vp-shim.exe`) is distributed alongside `vp.exe`: + +``` +~/.vite-plus/current/bin/ +├── vp.exe # Main CLI binary +└── vp-shim.exe # Trampoline template (copied as shims) +``` + +Included in: + +- Platform npm packages (`@voidzero-dev/vite-plus-cli-win32-x64-msvc`) +- Release artifacts (`.github/workflows/release.yml`) +- `install.ps1` and `install.sh` (both local dev and download paths) +- `extract_platform_package()` in the upgrade path + +### Legacy Fallback + +When installing a pre-trampoline version (no `vp-shim.exe` in the package): + +- `install.ps1` falls back to creating `.cmd` + shell script wrappers +- Stale trampoline `.exe` shims from a newer install are removed (`.exe` takes precedence over `.cmd` on Windows PATH) + +## Comparison with uv-trampoline + +| Aspect | uv-trampoline | vite-plus trampoline | +| ------------------- | ---------------------------------------- | ------------------------------------ | +| **Purpose** | Launch Python with embedded script | Forward to `vp.exe` | +| **Complexity** | High (PE resources, zipimport) | Low (filename + spawn) | +| **Data embedding** | PE resources (kind, path, script ZIP) | None (uses filename + relative path) | +| **Dependencies** | `windows` crate (unsafe, no CRT) | Zero (raw FFI declaration) | +| **Toolchain** | Nightly Rust (`panic="immediate-abort"`) | Stable Rust | +| **Binary size** | 39-47 KB | ~200 KB | +| **Entry point** | `#![no_main]` + `mainCRTStartup` | Standard `fn main()` | +| **Error output** | `ufmt` (no `core::fmt`) | `write_all` (no `core::fmt`) | +| **Ctrl+C handling** | `SetConsoleCtrlHandler` → ignore | Same approach | +| **Exit code** | `GetExitCodeProcess` → `exit()` | `Command::status()` → `exit()` | + +The vite-plus trampoline is significantly simpler because it doesn't need to embed data in PE resources — it just reads its own filename, finds `vp.exe` at a fixed relative path, and spawns it. The ~150KB size difference from uv-trampoline comes from `std::process::Command` (which internally pulls in `core::fmt`) versus raw `CreateProcessW` with nightly-only `#![no_main]`. + +## Alternatives Considered + +### 1. NTFS Hardlinks (Rejected) + +Hardlinks resolve to physical file inodes, not through directory junctions. After `vp` upgrade re-points `current`, hardlinks in `bin/` still reference the old binary. + +### 2. Windows Symbolic Links (Rejected) + +Requires administrator privileges or Developer Mode. Not reliable for all users. + +### 3. PowerShell `.ps1` Scripts (Rejected) + +`where.exe` and `which` do not find `.ps1` files. Only works in PowerShell. + +### 4. Copy `vp.exe` as Each Shim (Rejected) + +~5-10MB per copy. Trampoline achieves the same result at ~100KB. + +### 5. `windows` Crate for FFI (Rejected) + +Adds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI declaration is sufficient. + +## Future Optimizations + +If the ~100KB binary size needs to be reduced further: + +1. **Switch to nightly Rust** with `panic="immediate-abort"` and `#![no_main]` + `mainCRTStartup` (~50KB savings) +2. **Use raw Win32 `CreateProcessW`** instead of `std::process::Command` (eliminates most of std's process machinery) +3. **Pre-build and check in** trampoline binaries (like uv does) to decouple the trampoline build from the workspace toolchain + +These would bring the binary to ~40-50KB, matching uv-trampoline, at the cost of requiring a nightly toolchain and more unsafe code. + +## References + +- [Issue #835](https://github.com/voidzero-dev/vite-plus/issues/835): Original feature request with video reproduction +- [uv-trampoline](https://github.com/astral-sh/uv/tree/main/crates/uv-trampoline): Reference implementation by astral-sh (~40KB with nightly Rust) +- [RFC: env-command](./env-command.md): Shim architecture documentation +- [RFC: upgrade-command](./upgrade-command.md): Upgrade/rollback flow diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index ba77076c96..7780e02342 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -31,7 +31,7 @@ A native `vp upgrade` command would allow users to update the CLI in-place with └── env.ps1 # PowerShell env ``` -Key invariant: `~/.vite-plus/bin/vp` is a symlink to `../current/bin/vp` (Unix) or a `.cmd` wrapper calling `current\bin\vp.exe` (Windows), and `current` is a symlink (Unix) or junction (Windows) to the active version directory. Upgrading swaps the `current` link — atomic on Unix, near-instant on Windows. +Key invariant: `~/.vite-plus/bin/vp` is a symlink to `../current/bin/vp` (Unix) or a trampoline `.exe` forwarding to `current\bin\vp.exe` (Windows), and `current` is a symlink (Unix) or junction (Windows) to the active version directory. Upgrading swaps the `current` link — atomic on Unix, near-instant on Windows. ## Goals @@ -295,7 +295,7 @@ Key differences on Windows: - **Junctions** (`mklink /J`) are used instead of symlinks — junctions don't require admin privileges - Junctions only work for directories (which `current` is), and use absolute paths internally - The swap is **not atomic** — there's a brief window (~milliseconds) where `current` doesn't exist -- `bin/vp` is a `.cmd` wrapper (not a symlink), so it doesn't need updating during upgrade +- `bin/vp.exe` is a trampoline (not a symlink) that resolves through `current`, so it doesn't need updating during upgrade - This matches the existing `install.ps1` behavior exactly #### Step 6: Post-Update (Non-Fatal) @@ -314,7 +314,7 @@ The running `vp` process is **not** the binary being replaced. The flow is: ~/.vite-plus/bin/vp → ../current/bin/vp → {old_version}/bin/vp # Windows -~/.vite-plus/bin/vp.cmd → current\bin\vp.exe → {old_version}\bin\vp.exe +~/.vite-plus/bin/vp.exe (trampoline) → current\bin\vp.exe → {old_version}\bin\vp.exe ``` After the `current` link swap, any **new** invocation of `vp` will use the new binary. The currently running process continues to execute from the old version's binary file on disk: diff --git a/rfcs/vpx-command.md b/rfcs/vpx-command.md index 5d6081ff89..1fc645f5c9 100644 --- a/rfcs/vpx-command.md +++ b/rfcs/vpx-command.md @@ -155,13 +155,7 @@ if tool == "vpx" { ### Windows -On Windows, `vpx.cmd` is a wrapper script (consistent with existing `node.cmd`, `npm.cmd`, `npx.cmd` wrappers): - -```cmd -@echo off -set "VITE_PLUS_SHIM_TOOL=vpx" -"%~dp0..\current\bin\vp.exe" %* -``` +On Windows, `vpx.exe` is a trampoline executable (consistent with existing `node.exe`, `npm.exe`, `npx.exe` shims). It detects its tool name from its own filename (`vpx`), sets `VITE_PLUS_SHIM_TOOL=vpx`, and spawns `vp.exe`. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md). ### Setup