Skip to content

Commit 880a128

Browse files
committed
feat(env): use trampoline exe instead of .cmd wrappers on Windows
Replace Windows .cmd shim wrappers with lightweight trampoline .exe binaries to eliminate the "Terminate batch job (Y/N)?" prompt on Ctrl+C. The trampoline binary detects its tool name from its own filename, sets VITE_PLUS_SHIM_TOOL env var, and spawns vp.exe. It installs a Ctrl+C handler that ignores the signal (the child process handles it), avoiding the batch file prompt entirely. - Add crates/vite_trampoline/ with minimal Windows trampoline binary - Update shim detection to check env var before argv[0] - Replace .cmd/.sh wrapper creation with trampoline .exe copying - Add legacy .cmd cleanup during setup --refresh - Update CI to build and distribute vp-shim.exe for Windows targets - Add RFC document with feasibility study Closes #835
1 parent e3607ec commit 880a128

File tree

15 files changed

+861
-173
lines changed

15 files changed

+861
-173
lines changed

.github/actions/build-upstream/action.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ runs:
4040
packages/cli/binding/index.d.cts
4141
target/${{ inputs.target }}/release/vp
4242
target/${{ inputs.target }}/release/vp.exe
43+
target/${{ inputs.target }}/release/vp-shim.exe
4344
key: ${{ steps.cache-key.outputs.key }}
4445

4546
# Apply Vite+ branding patches to rolldown-vite source (CI checks out
@@ -111,6 +112,11 @@ runs:
111112
shell: bash
112113
run: cargo build --release --target ${{ inputs.target }} -p vite_global_cli
113114

115+
- name: Build trampoline shim binary (Windows only)
116+
if: steps.cache-restore.outputs.cache-hit != 'true' && contains(inputs.target, 'windows')
117+
shell: bash
118+
run: cargo build --release --target ${{ inputs.target }} -p vite_trampoline
119+
114120
- name: Save NAPI binding cache
115121
if: steps.cache-restore.outputs.cache-hit != 'true'
116122
uses: actions/cache/save@94b89442628ad1d101e352b7ee38f30e1bef108e # v5
@@ -123,6 +129,7 @@ runs:
123129
packages/cli/binding/index.d.cts
124130
target/${{ inputs.target }}/release/vp
125131
target/${{ inputs.target }}/release/vp.exe
132+
target/${{ inputs.target }}/release/vp-shim.exe
126133
key: ${{ steps.cache-key.outputs.key }}
127134

128135
# Build vite-plus TypeScript after native bindings are ready

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ jobs:
124124
path: |
125125
./target/${{ matrix.settings.target }}/release/vp
126126
./target/${{ matrix.settings.target }}/release/vp.exe
127+
./target/${{ matrix.settings.target }}/release/vp-shim.exe
127128
if-no-files-found: error
128129

129130
- name: Remove .node files before upload dist

.github/workflows/test-standalone-install.yml

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ jobs:
233233
exit 1
234234
}
235235
236-
$expectedShims = @("node.cmd", "npm.cmd", "npx.cmd")
236+
$expectedShims = @("node.exe", "npm.exe", "npx.exe")
237237
foreach ($shim in $expectedShims) {
238238
$shimFile = Join-Path $binPath $shim
239239
if (-not (Test-Path $shimFile)) {
@@ -300,7 +300,7 @@ jobs:
300300
exit 1
301301
}
302302
303-
$expectedShims = @("node.cmd", "npm.cmd", "npx.cmd")
303+
$expectedShims = @("node.exe", "npm.exe", "npx.exe")
304304
foreach ($shim in $expectedShims) {
305305
$shimFile = Join-Path $binPath $shim
306306
if (-not (Test-Path $shimFile)) {
@@ -380,8 +380,8 @@ jobs:
380380
exit 1
381381
}
382382
383-
# Verify shim executables exist (all use .cmd wrappers on Windows)
384-
$expectedShims = @("node.cmd", "npm.cmd", "npx.cmd")
383+
# Verify shim executables exist (trampoline .exe files on Windows)
384+
$expectedShims = @("node.exe", "npm.exe", "npx.exe")
385385
foreach ($shim in $expectedShims) {
386386
$shimFile = Join-Path $binPath $shim
387387
if (-not (Test-Path $shimFile)) {
@@ -419,8 +419,8 @@ jobs:
419419
set "BIN_PATH=%USERPROFILE%\.vite-plus\bin"
420420
dir "%BIN_PATH%"
421421
422-
REM Verify shim executables exist (Windows uses .cmd wrappers)
423-
for %%s in (node.cmd npm.cmd npx.cmd vp.cmd) do (
422+
REM Verify shim executables exist (Windows uses trampoline .exe files)
423+
for %%s in (node.exe npm.exe npx.exe vp.exe) do (
424424
if not exist "%BIN_PATH%\%%s" (
425425
echo Error: Shim not found: %BIN_PATH%\%%s
426426
exit /b 1
@@ -462,22 +462,13 @@ jobs:
462462
exit 1
463463
fi
464464
465-
# Verify .cmd wrappers exist (for cmd.exe/PowerShell)
466-
for shim in node.cmd npm.cmd npx.cmd vp.cmd; do
465+
# Verify trampoline .exe files exist
466+
for shim in node.exe npm.exe npx.exe vp.exe; do
467467
if [ ! -f "$BIN_PATH/$shim" ]; then
468-
echo "Error: .cmd wrapper not found: $BIN_PATH/$shim"
468+
echo "Error: Trampoline shim not found: $BIN_PATH/$shim"
469469
exit 1
470470
fi
471-
echo "Found .cmd wrapper: $BIN_PATH/$shim"
472-
done
473-
474-
# Verify shell scripts exist (for Git Bash)
475-
for shim in node npm npx vp; do
476-
if [ ! -f "$BIN_PATH/$shim" ]; then
477-
echo "Error: Shell script not found: $BIN_PATH/$shim"
478-
exit 1
479-
fi
480-
echo "Found shell script: $BIN_PATH/$shim"
471+
echo "Found trampoline shim: $BIN_PATH/$shim"
481472
done
482473
483474
# Verify vp env doctor works

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_global_cli/src/commands/env/doctor.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,8 @@ async fn check_bin_dir() -> bool {
239239
fn shim_filename(tool: &str) -> String {
240240
#[cfg(windows)]
241241
{
242-
// All tools use .cmd wrappers on Windows (including node)
243-
format!("{tool}.cmd")
242+
// All tools use trampoline .exe files on Windows
243+
format!("{tool}.exe")
244244
}
245245

246246
#[cfg(not(windows))]
@@ -739,10 +739,10 @@ mod tests {
739739

740740
#[cfg(windows)]
741741
{
742-
// All shims should use .cmd on Windows (matching setup.rs)
743-
assert_eq!(node, "node.cmd");
744-
assert_eq!(npm, "npm.cmd");
745-
assert_eq!(npx, "npx.cmd");
742+
// All shims should use .exe on Windows (trampoline executables)
743+
assert_eq!(node, "node.exe");
744+
assert_eq!(npm, "npm.exe");
745+
assert_eq!(npx, "npx.exe");
746746
}
747747

748748
#[cfg(not(windows))]

crates/vite_global_cli/src/commands/env/global_install.rs

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"];
368368
/// Create a shim for a package binary.
369369
///
370370
/// On Unix: Creates a symlink to ../current/bin/vp
371-
/// On Windows: Creates a .cmd wrapper that calls `vp env exec <bin_name>`
371+
/// On Windows: Creates a trampoline .exe that forwards to vp.exe
372372
async fn create_package_shim(
373373
bin_dir: &vite_path::AbsolutePath,
374374
bin_name: &str,
@@ -406,40 +406,25 @@ async fn create_package_shim(
406406

407407
#[cfg(windows)]
408408
{
409-
let cmd_path = bin_dir.join(format!("{}.cmd", bin_name));
409+
let shim_path = bin_dir.join(format!("{}.exe", bin_name));
410410

411411
// Skip if already exists (e.g., re-installing the same package)
412-
if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) {
412+
if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {
413413
return Ok(());
414414
}
415415

416-
// Create .cmd wrapper that calls vp env exec <bin_name>.
417-
// Use `--` so args like `--help` are forwarded to the package binary,
418-
// not consumed by clap while parsing `vp env exec`.
419-
// Set VITE_PLUS_HOME using %~dp0.. which resolves to the parent of bin/
420-
// This ensures the vp binary knows its home directory
421-
let wrapper_content = format!(
422-
"@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",
423-
bin_name
424-
);
425-
tokio::fs::write(&cmd_path, wrapper_content).await?;
416+
// Copy the trampoline binary as <bin_name>.exe.
417+
// The trampoline detects the tool name from its own filename and sets
418+
// VITE_PLUS_SHIM_TOOL env var before spawning vp.exe.
419+
let trampoline_src = super::setup::get_trampoline_path()?;
420+
tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?;
426421

427-
// Also create shell script for Git Bash (bin_name without extension)
428-
// Uses explicit "vp env exec <bin_name>" instead of symlink+argv[0] because
429-
// Windows symlinks require admin privileges
430-
let sh_path = bin_dir.join(bin_name);
431-
let sh_content = format!(
432-
r#"#!/bin/sh
433-
VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")"
434-
export VITE_PLUS_HOME
435-
export VITE_PLUS_SHIM_WRAPPER=1
436-
exec "$VITE_PLUS_HOME/current/bin/vp.exe" env exec {} -- "$@"
437-
"#,
438-
bin_name
439-
);
440-
tokio::fs::write(&sh_path, sh_content).await?;
422+
// Remove legacy .cmd and shell script wrappers from previous versions.
423+
// In Git Bash/MSYS, the extensionless script takes precedence over .exe,
424+
// so leftover wrappers would bypass the trampoline.
425+
super::setup::cleanup_legacy_windows_shim(bin_dir, bin_name).await;
441426

442-
tracing::debug!("Created package shim wrappers for {} (.cmd and shell script)", bin_name);
427+
tracing::debug!("Created package trampoline shim {:?}", shim_path);
443428
}
444429

445430
Ok(())
@@ -466,13 +451,17 @@ async fn remove_package_shim(
466451

467452
#[cfg(windows)]
468453
{
469-
// Remove .cmd wrapper
454+
// Remove trampoline .exe shim
455+
let exe_path = bin_dir.join(format!("{}.exe", bin_name));
456+
if tokio::fs::try_exists(&exe_path).await.unwrap_or(false) {
457+
tokio::fs::remove_file(&exe_path).await?;
458+
}
459+
460+
// Also remove legacy .cmd wrapper and shell script from previous versions
470461
let cmd_path = bin_dir.join(format!("{}.cmd", bin_name));
471462
if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) {
472463
tokio::fs::remove_file(&cmd_path).await?;
473464
}
474-
475-
// Also remove shell script (for Git Bash)
476465
let sh_path = bin_dir.join(bin_name);
477466
if tokio::fs::try_exists(&sh_path).await.unwrap_or(false) {
478467
tokio::fs::remove_file(&sh_path).await?;
@@ -486,13 +475,34 @@ async fn remove_package_shim(
486475
mod tests {
487476
use super::*;
488477

478+
/// On Windows, create a fake trampoline binary and set the env var so
479+
/// `get_trampoline_path()` finds it instead of looking next to the test harness.
480+
#[cfg(windows)]
481+
fn setup_fake_trampoline(dir: &std::path::Path) {
482+
let trampoline = dir.join("vp-shim.exe");
483+
std::fs::write(&trampoline, b"fake-trampoline").unwrap();
484+
unsafe {
485+
std::env::set_var("VITE_PLUS_TRAMPOLINE_PATH", &trampoline);
486+
}
487+
}
488+
489+
/// Clean up the trampoline env var after a Windows test.
490+
#[cfg(windows)]
491+
fn cleanup_fake_trampoline() {
492+
unsafe {
493+
std::env::remove_var("VITE_PLUS_TRAMPOLINE_PATH");
494+
}
495+
}
496+
489497
#[tokio::test]
490498
async fn test_create_package_shim_creates_bin_dir() {
491499
use tempfile::TempDir;
492500
use vite_path::AbsolutePathBuf;
493501

494502
// Create a temp directory but don't create the bin subdirectory
495503
let temp_dir = TempDir::new().unwrap();
504+
#[cfg(windows)]
505+
setup_fake_trampoline(temp_dir.path());
496506
let bin_dir = temp_dir.path().join("bin");
497507
let bin_dir = AbsolutePathBuf::new(bin_dir).unwrap();
498508

@@ -501,11 +511,13 @@ mod tests {
501511

502512
// Create a shim - this should create the bin directory
503513
create_package_shim(&bin_dir, "test-shim", "test-package").await.unwrap();
514+
#[cfg(windows)]
515+
cleanup_fake_trampoline();
504516

505517
// Verify bin directory was created
506518
assert!(bin_dir.as_path().exists());
507519

508-
// Verify shim file was created (on Windows, shims have .cmd extension)
520+
// Verify shim file was created (on Windows, shims have .exe extension)
509521
// On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata
510522
#[cfg(unix)]
511523
{
@@ -517,7 +529,7 @@ mod tests {
517529
}
518530
#[cfg(windows)]
519531
{
520-
let shim_path = bin_dir.join("test-shim.cmd");
532+
let shim_path = bin_dir.join("test-shim.exe");
521533
assert!(shim_path.as_path().exists());
522534
}
523535
}
@@ -537,7 +549,7 @@ mod tests {
537549
#[cfg(unix)]
538550
let shim_path = bin_dir.join("node");
539551
#[cfg(windows)]
540-
let shim_path = bin_dir.join("node.cmd");
552+
let shim_path = bin_dir.join("node.exe");
541553
assert!(!shim_path.as_path().exists());
542554
}
543555

@@ -547,6 +559,8 @@ mod tests {
547559
use vite_path::AbsolutePathBuf;
548560

549561
let temp_dir = TempDir::new().unwrap();
562+
#[cfg(windows)]
563+
setup_fake_trampoline(temp_dir.path());
550564
let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
551565

552566
// Create a shim
@@ -573,7 +587,7 @@ mod tests {
573587
}
574588
#[cfg(windows)]
575589
{
576-
let shim_path = bin_dir.join("tsc.cmd");
590+
let shim_path = bin_dir.join("tsc.exe");
577591
assert!(shim_path.as_path().exists(), "Shim should exist after creation");
578592

579593
// Remove the shim
@@ -603,6 +617,8 @@ mod tests {
603617

604618
let temp_dir = TempDir::new().unwrap();
605619
let temp_path = temp_dir.path().to_path_buf();
620+
#[cfg(windows)]
621+
setup_fake_trampoline(&temp_path);
606622
let _guard = vite_shared::EnvConfig::test_guard(
607623
vite_shared::EnvConfig::for_test_with_home(&temp_path),
608624
);
@@ -630,10 +646,10 @@ mod tests {
630646
}
631647
#[cfg(windows)]
632648
{
633-
assert!(bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should exist");
649+
assert!(bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should exist");
634650
assert!(
635-
bin_dir.join("tsserver.cmd").as_path().exists(),
636-
"tsserver.cmd shim should exist"
651+
bin_dir.join("tsserver.exe").as_path().exists(),
652+
"tsserver.exe shim should exist"
637653
);
638654
}
639655

@@ -674,10 +690,10 @@ mod tests {
674690
}
675691
#[cfg(windows)]
676692
{
677-
assert!(!bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should be removed");
693+
assert!(!bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should be removed");
678694
assert!(
679-
!bin_dir.join("tsserver.cmd").as_path().exists(),
680-
"tsserver.cmd shim should be removed"
695+
!bin_dir.join("tsserver.exe").as_path().exists(),
696+
"tsserver.exe shim should be removed"
681697
);
682698
}
683699
}

0 commit comments

Comments
 (0)