From 0b699acef5d24f0ff4bd71ad4f55d7557bc0f365 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 17 Sep 2026 01:54:13 +0800 Subject: [PATCH 01/11] fix(cli): resolve Windows shims in case-sensitive directories --- .../windows_case_sensitive_shims/package.json | 8 + .../packages/app/package.json | 7 + .../packages/app/print.cjs | 1 + .../packages/app/vite.config.ts | 9 + .../pnpm-workspace.yaml | 2 + .../windows_case_sensitive_shims/setup.cjs | 13 + .../snapshots.toml | 15 ++ .../windows_case_sensitive_shims.global.md | 56 +++++ .../windows_case_sensitive_shims.local.md | 56 +++++ crates/vp_command/src/lib.rs | 10 +- crates/vp_command/src/windows_resolve.rs | 227 ++++++++++++++++++ packages/cli/binding/src/cli/handler.rs | 56 +++++ 12 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/print.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/vite.config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md create mode 100644 crates/vp_command/src/windows_resolve.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/package.json new file mode 100644 index 0000000000..349202b9ec --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/package.json @@ -0,0 +1,8 @@ +{ + "name": "case-sensitive-shims", + "private": true, + "packageManager": "pnpm@12.4.1", + "scripts": { + "probe": "vp run --filter @fixture/app probe" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json new file mode 100644 index 0000000000..92eed585a4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fixture/app", + "private": true, + "scripts": { + "probe": "astro --version" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/print.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/print.cjs new file mode 100644 index 0000000000..2397d28d99 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/print.cjs @@ -0,0 +1 @@ +console.log('local shim', ...process.argv.slice(2)); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/vite.config.ts new file mode 100644 index 0000000000..d6d4b1aeea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/vite.config.ts @@ -0,0 +1,9 @@ +export default { + run: { + tasks: { + cached: { + command: "astro cached", + }, + }, + }, +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs new file mode 100644 index 0000000000..4908fc845c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs @@ -0,0 +1,13 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const bin = path.resolve('packages/app/node_modules/.bin'); +fs.mkdirSync(bin, { recursive: true }); +// The directory must be empty when case sensitivity is enabled. Do not depend +// on the checkout's NTFS flags or the casing chosen by a package manager. +execFileSync('fsutil.exe', ['file', 'setCaseSensitiveInfo', bin, 'enable']); +fs.writeFileSync(path.join(bin, 'astro.cmd'), '@echo off\r\nnode "%~dp0/../../print.cjs" %*\r\n'); +assert.equal(fs.existsSync(path.join(bin, 'astro.CMD')), false); +assert.equal(process.env.PATHEXT, '.COM;.EXE;.BAT;.CMD'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml new file mode 100644 index 0000000000..e39fd78af1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml @@ -0,0 +1,15 @@ +[[case]] +name = "windows_case_sensitive_shims" +vp = ["local", "global"] +skip-platforms = ["linux", "macos"] +env = { PATHEXT = ".COM;.EXE;.BAT;.CMD" } +steps = [ + { argv = ["node", "setup.cjs"], snapshot = false }, + { argv = ["vp", "run", "probe"], tty = false, comment = "Nested task planning resolves the lowercase shim with uppercase PATHEXT." }, + { argv = ["vp", "exec", "astro", "--version"], cwd = "packages/app", tty = false }, + { argv = ["vpt", "write-file", "node_modules/.bin/astro.CMD", "@echo wrong workspace shim %*"], snapshot = false }, + { argv = ["vp", "run", "probe"], tty = false, comment = "The earlier package-local lowercase shim wins over a later uppercase shim." }, + { argv = ["vp", "exec", "astro", "--version"], cwd = "packages/app", tty = false }, + { argv = ["vp", "run", "cached"], cwd = "packages/app", tty = false }, + { argv = ["vp", "run", "cached"], cwd = "packages/app", tty = false, comment = "Resolving the program keeps the task's cache behavior." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md new file mode 100644 index 0000000000..567477212b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md @@ -0,0 +1,56 @@ +# windows_case_sensitive_shims + +## `node setup.cjs` + + +## `vp run probe` + +Nested task planning resolves the lowercase shim with uppercase PATHEXT. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `vpt write-file node_modules/.bin/astro.CMD "@echo wrong workspace shim %*"` + + +## `vp run probe` + +The earlier package-local lowercase shim wins over a later uppercase shim. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `cd packages/app && vp run cached` + +``` +~/packages/app$ astro cached +local shim cached +``` + +## `cd packages/app && vp run cached` + +Resolving the program keeps the task's cache behavior. + +``` +~/packages/app$ astro cached ◉ cache hit, replaying +local shim cached + +--- +vp run: cache hit, saved. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md new file mode 100644 index 0000000000..567477212b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md @@ -0,0 +1,56 @@ +# windows_case_sensitive_shims + +## `node setup.cjs` + + +## `vp run probe` + +Nested task planning resolves the lowercase shim with uppercase PATHEXT. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `vpt write-file node_modules/.bin/astro.CMD "@echo wrong workspace shim %*"` + + +## `vp run probe` + +The earlier package-local lowercase shim wins over a later uppercase shim. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `cd packages/app && vp run cached` + +``` +~/packages/app$ astro cached +local shim cached +``` + +## `cd packages/app && vp run cached` + +Resolving the program keeps the task's cache behavior. + +``` +~/packages/app$ astro cached ◉ cache hit, replaying +local shim cached + +--- +vp run: cache hit, saved. +``` diff --git a/crates/vp_command/src/lib.rs b/crates/vp_command/src/lib.rs index cb47683d49..e23597349b 100644 --- a/crates/vp_command/src/lib.rs +++ b/crates/vp_command/src/lib.rs @@ -20,6 +20,8 @@ use vp_error::Error; use vt_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; mod ps1_shim; +#[cfg(any(windows, test))] +mod windows_resolve; /// Result of running a command with fspy tracking. #[derive(Debug)] @@ -30,8 +32,8 @@ pub struct FspyCommandResult { pub path_accesses: HashMap, } -/// Resolve a binary name to a full path using the `which` crate. -/// Handles PATHEXT (`.cmd`/`.bat`) resolution natively on Windows. +/// Resolve a binary name to a full path, including PATHEXT on Windows. +/// Windows filename matching also works in case-sensitive directories. /// /// If `path_env` is `None`, searches the process's current `PATH`. pub fn resolve_bin( @@ -46,6 +48,10 @@ pub fn resolve_bin( current_path = std::env::var_os("PATH").unwrap_or_default(); ¤t_path }; + #[cfg(windows)] + let path = windows_resolve::resolve(bin_name, path_env, cwd.as_ref()) + .ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into()))?; + #[cfg(not(windows))] let path = which::which_in(bin_name, Some(path_env), cwd.as_ref()) .map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?; AbsolutePathBuf::new(path).ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into())) diff --git a/crates/vp_command/src/windows_resolve.rs b/crates/vp_command/src/windows_resolve.rs new file mode 100644 index 0000000000..1f47f6509e --- /dev/null +++ b/crates/vp_command/src/windows_resolve.rs @@ -0,0 +1,227 @@ +//! Resolve Windows executable candidates using their on-disk filename casing. +//! +//! `which` 8 checks whether a PATHEXT candidate exists before correcting its +//! casing. In a case-sensitive directory, `.CMD` therefore misses pnpm's `.cmd` +//! shim. Correct each candidate before checking it, preserving PATH/PATHEXT order. + +use std::{ + ffi::{OsStr, OsString}, + path::{Component, Path, PathBuf}, +}; + +use vt_path::AbsolutePath; +use which::sys::{RealSys, Sys}; + +#[cfg(windows)] +pub(super) fn resolve(bin: &str, paths: &OsStr, cwd: &AbsolutePath) -> Option { + resolve_with_extensions(bin, paths, cwd, &RealSys.env_windows_path_ext()) +} + +fn resolve_with_extensions( + bin: &str, + paths: &OsStr, + cwd: &AbsolutePath, + extensions: &[String], +) -> Option { + let bin = Path::new(bin); + if bin.is_absolute() || bin.components().count() > 1 { + return resolve_path(&cwd.as_path().join(bin), extensions); + } + + std::env::split_paths(paths).filter(|dir| !dir.as_os_str().is_empty()).find_map(|dir| { + let mut components = dir.components(); + let dir = if components.next() == Some(Component::Normal(OsStr::new("~"))) { + RealSys.home_dir().map_or_else(|| dir.clone(), |home| home.join(components.as_path())) + } else { + dir + }; + resolve_path(&cwd.as_path().join(dir).join(bin), extensions) + }) +} + +fn resolve_path(path: &Path, extensions: &[String]) -> Option { + let parent = path.parent()?; + let name = path.file_name()?; + // One listing for all extensions. If enumeration is unavailable, retain + // direct lookup so searchable directories do not require list permission. + let names = std::fs::read_dir(parent) + .and_then(|entries| { + entries + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::>>() + }) + .ok(); + let has_extension = path.extension().is_some_and(|ext| { + extensions.iter().any(|candidate| { + candidate.strip_prefix('.').is_some_and(|candidate| ext.eq_ignore_ascii_case(candidate)) + }) + }); + let candidates = std::iter::once(name.to_owned()).chain( + extensions.iter().take(if has_extension { 0 } else { extensions.len() }).map(|ext| { + let mut candidate = name.to_owned(); + candidate.push(ext); + candidate + }), + ); + for candidate in candidates { + // Retain native lookup when ASCII matching finds nothing: an ordinary + // Windows directory may also equate non-ASCII filename casing. + let actual = + names.as_ref().and_then(|names| match_name(names, &candidate)).unwrap_or(&candidate); + let path = parent.join(actual); + // Match which's Windows validation, including executable files without + // an extension and application execution aliases (reparse points). + if std::fs::symlink_metadata(&path).is_ok_and(|meta| meta.is_file() || meta.is_symlink()) + && (path.extension().is_some() || RealSys.is_valid_executable(&path).unwrap_or(false)) + { + return Some(path); + } + } + None +} + +fn match_name<'a>(names: &'a [OsString], candidate: &OsStr) -> Option<&'a OsStr> { + names + .iter() + .find(|name| *name == candidate) + .or_else(|| names.iter().find(|name| name.eq_ignore_ascii_case(candidate))) + .map(OsString::as_os_str) +} + +#[cfg(test)] +mod tests { + use vt_path::AbsolutePathBuf; + + use super::*; + + fn resolve_in( + bin: &str, + dirs: &[PathBuf], + cwd: &AbsolutePath, + ext: &[&str], + ) -> Option { + resolve_with_extensions( + bin, + &std::env::join_paths(dirs).unwrap(), + cwd, + &ext.iter().map(|ext| (*ext).to_owned()).collect::>(), + ) + } + + #[test] + fn resolves_shim_casing_before_trying_later_path_directories() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + let local = cwd.as_path().join("local"); + let global = cwd.as_path().join("global"); + std::fs::create_dir(&local).unwrap(); + std::fs::create_dir(&global).unwrap(); + std::fs::write(local.join("astro.cmd"), "").unwrap(); + std::fs::write(global.join("astro.EXE"), "").unwrap(); + assert_eq!( + resolve_in("astro", &[local.clone(), global], &cwd, &[".EXE", ".CMD"]), + Some(local.join("astro.cmd")) + ); + } + + #[test] + fn preserves_pathext_order_and_matches_mixed_case() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + std::fs::write(temp.path().join("AsTrO.cMd"), "").unwrap(); + std::fs::write(temp.path().join("astro.BAT"), "").unwrap(); + let dirs = [cwd.as_path().to_owned()]; + assert_eq!( + resolve_in("astro", &dirs, &cwd, &[".CMD", ".BAT"]), + Some(cwd.as_path().join("AsTrO.cMd")) + ); + assert_eq!( + resolve_in("astro", &dirs, &cwd, &[".BAT", ".CMD"]), + Some(cwd.as_path().join("astro.BAT")) + ); + } + + #[test] + fn prefers_exact_spelling_when_both_casings_exist() { + let names = [OsString::from("astro.cmd"), OsString::from("astro.CMD")]; + assert_eq!(match_name(&names, OsStr::new("astro.CMD")), Some(OsStr::new("astro.CMD"))); + assert_eq!(match_name(&names, OsStr::new("astro.cmd")), Some(OsStr::new("astro.cmd"))); + } + + #[test] + fn resolves_relative_path_entries_against_task_cwd_and_skips_missing_entries() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + std::fs::create_dir(temp.path().join("bin")).unwrap(); + std::fs::write(temp.path().join("bin/tool.cmd"), "").unwrap(); + assert_eq!( + resolve_in("tool", &["missing".into(), "./bin".into()], &cwd, &[".CMD"]), + Some(cwd.as_path().join("./bin/tool.cmd")) + ); + } + + #[test] + fn resolves_explicit_paths_without_searching_path() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + std::fs::create_dir(temp.path().join("bin")).unwrap(); + let actual = cwd.as_path().join("bin/tool.cmd"); + std::fs::write(&actual, "").unwrap(); + for bin in ["bin/tool", "bin/tool.CMD", actual.to_str().unwrap()] { + assert_eq!(resolve_in(bin, &[], &cwd, &[".CMD"]), Some(actual.clone())); + } + } + + #[test] + fn skips_directories_and_does_not_append_to_a_recognized_extension() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + std::fs::create_dir(temp.path().join("tool.EXE")).unwrap(); + std::fs::write(temp.path().join("tool.cmd"), "").unwrap(); + std::fs::write(temp.path().join("tool.EXE.cmd"), "").unwrap(); + let dirs = [cwd.as_path().to_owned()]; + assert_eq!( + resolve_in("tool", &dirs, &cwd, &[".EXE", ".CMD"]), + Some(cwd.as_path().join("tool.cmd")) + ); + assert_eq!(resolve_in("tool.EXE", &dirs, &cwd, &[".EXE", ".CMD"]), None); + } + + #[test] + fn respects_empty_path_and_pathext() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + std::fs::write(temp.path().join("tool.cmd"), "").unwrap(); + assert_eq!(resolve_in("tool", &[PathBuf::new()], &cwd, &[".CMD"]), None); + let dirs = [cwd.as_path().to_owned()]; + assert_eq!(resolve_in("tool", &dirs, &cwd, &[]), None); + assert_eq!(resolve_in("tool.cmd", &dirs, &cwd, &[]), Some(cwd.as_path().join("tool.cmd"))); + } + + #[test] + fn tries_unrecognized_extensions_verbatim_before_appending_pathext() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + std::fs::write(temp.path().join("tool.js"), "").unwrap(); + std::fs::write(temp.path().join("other.js.cmd"), "").unwrap(); + let dirs = [cwd.as_path().to_owned()]; + assert_eq!( + resolve_in("tool.js", &dirs, &cwd, &[".CMD"]), + Some(cwd.as_path().join("tool.js")) + ); + assert_eq!( + resolve_in("other.js", &dirs, &cwd, &[".CMD"]), + Some(cwd.as_path().join("other.js.cmd")) + ); + } + + #[cfg(windows)] + #[test] + fn retains_native_non_ascii_case_matching() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); + std::fs::write(temp.path().join("über.cmd"), "").unwrap(); + let result = resolve_in("ÜBER", &[cwd.as_path().to_owned()], &cwd, &[".CMD"]); + assert!(result.unwrap().is_file()); + } +} diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index a99fc70fa4..85c06a88d9 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -42,6 +42,22 @@ impl CommandHandler for VitePlusCommandHandler { // `vpr build`, etc. are synthesized in-session rather than spawning a new CLI process. let program = command.program.as_str(); if program != "vp" && program != "vpr" { + // Vite Task resolves verbatim commands itself. Supply the actual + // filename before its which lookup, which otherwise misses .cmd + // shims in case-sensitive Windows directories with .CMD in PATHEXT. + #[cfg(windows)] + if let Ok(path) = vp_command::resolve_bin( + program, + command + .envs + .iter() + .find(|(key, _)| super::is_path_env_key(key)) + .map(|(_, value)| value.as_ref()), + &command.cwd, + ) && let Some(path) = path.as_path().to_str() + { + command.program = path.into(); + } return Ok(HandledCommand::Verbatim); } @@ -112,6 +128,46 @@ impl CommandHandler for VitePlusCommandHandler { } } +#[cfg(all(test, windows))] +mod tests { + use std::{ffi::OsStr, sync::Arc}; + + use rustc_hash::FxHashMap; + use vt_path::AbsolutePathBuf; + + use super::*; + + #[tokio::test] + async fn verbatim_commands_use_the_task_path_with_any_key_casing() { + let temp = tempfile::tempdir().unwrap(); + let cwd: Arc = + AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap().into(); + let shim = cwd.as_path().join("task-only.cmd"); + std::fs::write(&shim, "@echo off\r\n").unwrap(); + let mut handler = VitePlusCommandHandler::new(SubcommandResolver::new(cwd.clone())); + for key in ["PATH", "Path", "path"] { + let envs = Arc::new(FxHashMap::from_iter([( + Arc::from(OsStr::new(key)), + Arc::from(cwd.as_path().as_os_str()), + )])); + let mut command = ScriptCommand { + program: "task-only.CMD".into(), + args: Arc::from([Str::from("argument with spaces")]), + envs: envs.clone(), + cwd: cwd.clone(), + }; + assert!(matches!( + handler.handle_command(&mut command).await.unwrap(), + HandledCommand::Verbatim + )); + assert_eq!(command.program.as_str(), shim.to_str().unwrap()); + assert_eq!(command.args.as_ref(), [Str::from("argument with spaces")]); + assert!(Arc::ptr_eq(&command.envs, &envs)); + assert!(Arc::ptr_eq(&command.cwd, &cwd)); + } + } +} + /// User config loader that resolves vite.config.ts via JavaScript callback pub(super) struct VitePlusConfigLoader { resolve_fn: ViteConfigResolverFn, From 5355a8139de0090e95881df7100d83b2564b465e Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 17 Sep 2026 01:54:38 +0800 Subject: [PATCH 02/11] chore(ci): enable case-sensitive directory tests on Windows --- .github/workflows/ci.yml | 9 +++++++++ .../fixtures/windows_case_sensitive_shims/setup.cjs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 897275001d..ed9501d777 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1035,6 +1035,15 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - uses: ./.github/actions/clone + # Windows Server images without WSL can leave per-directory NTFS case + # sensitivity disabled. The shim regression fixture enables it on an + # empty directory and asserts that differently cased names are distinct. + - name: Enable per-directory NTFS case sensitivity + shell: pwsh + run: | + New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' ` + -Name NtfsEnableDirCaseSensitivity -PropertyType DWord -Value 1 -Force | Out-Null + # Route TEMP/TMP onto a Dev Drive (ReFS): staged fixture workspaces # live under os.tmpdir() and must resolve pnpm junction reparse points # cleanly. NTFS-on-C: preserves the junction-target backslashes during diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs index 4908fc845c..972f6b4710 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs @@ -7,7 +7,7 @@ const bin = path.resolve('packages/app/node_modules/.bin'); fs.mkdirSync(bin, { recursive: true }); // The directory must be empty when case sensitivity is enabled. Do not depend // on the checkout's NTFS flags or the casing chosen by a package manager. -execFileSync('fsutil.exe', ['file', 'setCaseSensitiveInfo', bin, 'enable']); +execFileSync('fsutil.exe', ['file', 'setCaseSensitiveInfo', bin, 'enable'], { stdio: 'inherit' }); fs.writeFileSync(path.join(bin, 'astro.cmd'), '@echo off\r\nnode "%~dp0/../../print.cjs" %*\r\n'); assert.equal(fs.existsSync(path.join(bin, 'astro.CMD')), false); assert.equal(process.env.PATHEXT, '.COM;.EXE;.BAT;.CMD'); From 0682f02edaee76ce9555893760b9306345834103 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 17 Sep 2026 01:54:42 +0800 Subject: [PATCH 03/11] chore(ci): diagnose Windows case-sensitive directory support --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed9501d777..9fa6e73c6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,28 @@ defaults: shell: bash jobs: + windows-case-sensitivity-probe: + runs-on: namespace-profile-windows-4c-8g + steps: + - name: Inspect case-sensitive directory support + shell: pwsh + run: | + Get-Volume | Format-Table DriveLetter, FileSystem, FileSystemLabel + $key = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' + Get-ItemProperty -Path $key -Name NtfsEnableDirCaseSensitivity -ErrorAction SilentlyContinue + New-ItemProperty -Path $key -Name NtfsEnableDirCaseSensitivity -PropertyType DWord -Value 1 -Force | Out-Null + Get-ItemProperty -Path $key -Name NtfsEnableDirCaseSensitivity + foreach ($root in @([System.IO.Path]::GetTempPath(), $env:RUNNER_TEMP)) { + $probe = Join-Path $root ('vp-case-probe-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $probe -Force | Out-Null + Write-Output "Testing $probe" + fsutil.exe fsinfo volumeinfo ([System.IO.Path]::GetPathRoot($probe)) + fsutil.exe file setCaseSensitiveInfo $probe enable + Write-Output "setCaseSensitiveInfo exit: $LASTEXITCODE" + fsutil.exe file queryCaseSensitiveInfo $probe + } + exit 0 + detect-changes: runs-on: ubuntu-latest permissions: From 0eb3938718417c35fd6cc41bec2e591d93973f89 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 17 Sep 2026 01:54:45 +0800 Subject: [PATCH 04/11] chore(ci): stage Windows snapshots on the workspace volume --- .github/workflows/ci.yml | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fa6e73c6f..aad8d480a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,28 +28,6 @@ defaults: shell: bash jobs: - windows-case-sensitivity-probe: - runs-on: namespace-profile-windows-4c-8g - steps: - - name: Inspect case-sensitive directory support - shell: pwsh - run: | - Get-Volume | Format-Table DriveLetter, FileSystem, FileSystemLabel - $key = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' - Get-ItemProperty -Path $key -Name NtfsEnableDirCaseSensitivity -ErrorAction SilentlyContinue - New-ItemProperty -Path $key -Name NtfsEnableDirCaseSensitivity -PropertyType DWord -Value 1 -Force | Out-Null - Get-ItemProperty -Path $key -Name NtfsEnableDirCaseSensitivity - foreach ($root in @([System.IO.Path]::GetTempPath(), $env:RUNNER_TEMP)) { - $probe = Join-Path $root ('vp-case-probe-' + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $probe -Force | Out-Null - Write-Output "Testing $probe" - fsutil.exe fsinfo volumeinfo ([System.IO.Path]::GetPathRoot($probe)) - fsutil.exe file setCaseSensitiveInfo $probe enable - Write-Output "setCaseSensitiveInfo exit: $LASTEXITCODE" - fsutil.exe file queryCaseSensitiveInfo $probe - } - exit 0 - detect-changes: runs-on: ubuntu-latest permissions: @@ -1057,15 +1035,6 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - uses: ./.github/actions/clone - # Windows Server images without WSL can leave per-directory NTFS case - # sensitivity disabled. The shim regression fixture enables it on an - # empty directory and asserts that differently cased names are distinct. - - name: Enable per-directory NTFS case sensitivity - shell: pwsh - run: | - New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' ` - -Name NtfsEnableDirCaseSensitivity -PropertyType DWord -Value 1 -Force | Out-Null - # Route TEMP/TMP onto a Dev Drive (ReFS): staged fixture workspaces # live under os.tmpdir() and must resolve pnpm junction reparse points # cleanly. NTFS-on-C: preserves the junction-target backslashes during @@ -1155,6 +1124,10 @@ jobs: cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 env: RUST_BACKTRACE: '1' + # Rust's GetTempPath2 ignores TEMP/TMP under the SYSTEM account. + # Use the runner's NTFS workspace volume: C:\Windows\SystemTemp + # rejects the case-sensitive directory flag on these hosts. + SystemTemp: ${{ runner.temp }} # Keep Windows env parity with the `test` recipe in justfile. __COMPAT_LAYER: RunAsInvoker From f92a54fe86515eb98fa503e1dced16d637888a58 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 17 Sep 2026 01:54:52 +0800 Subject: [PATCH 05/11] chore(ci): isolate Windows snapshot workspaces --- .github/workflows/ci.yml | 13 ++++++++++++- .../windows_case_sensitive_shims/later-shim.cjs | 4 ++++ .../windows_case_sensitive_shims/snapshots.toml | 2 +- .../windows_case_sensitive_shims.global.md | 2 +- .../snapshots/windows_case_sensitive_shims.local.md | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/later-shim.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aad8d480a3..72cbba77e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1102,6 +1102,17 @@ jobs: with: tool: cargo-nextest + # Keep fixtures outside runner.temp: setup-node writes an .npmrc there, + # which Yarn Classic discovers when walking parent directories. + - name: Create NTFS snapshot temp directory + id: snapshot-temp + shell: pwsh + run: | + $volume = [System.IO.Path]::GetPathRoot($env:RUNNER_TEMP) + $snapshotTemp = Join-Path $volume ('vp-snapshots-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $snapshotTemp | Out-Null + "directory=$snapshotTemp" >> $env:GITHUB_OUTPUT + # `cargo-nextest` is invoked directly so the job never depends on the # runner's Rust toolchain. --workspace-remap makes nextest rewrite # CARGO_MANIFEST_DIR and CARGO_BIN_EXE_vpt to this checkout, which is @@ -1127,7 +1138,7 @@ jobs: # Rust's GetTempPath2 ignores TEMP/TMP under the SYSTEM account. # Use the runner's NTFS workspace volume: C:\Windows\SystemTemp # rejects the case-sensitive directory flag on these hosts. - SystemTemp: ${{ runner.temp }} + SystemTemp: ${{ steps.snapshot-temp.outputs.directory }} # Keep Windows env parity with the `test` recipe in justfile. __COMPAT_LAYER: RunAsInvoker diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/later-shim.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/later-shim.cjs new file mode 100644 index 0000000000..c1c6e6807f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/later-shim.cjs @@ -0,0 +1,4 @@ +const fs = require('node:fs'); + +fs.mkdirSync('node_modules/.bin', { recursive: true }); +fs.writeFileSync('node_modules/.bin/astro.CMD', '@echo wrong workspace shim %*\r\n'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml index e39fd78af1..bb7c073b06 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml @@ -7,7 +7,7 @@ steps = [ { argv = ["node", "setup.cjs"], snapshot = false }, { argv = ["vp", "run", "probe"], tty = false, comment = "Nested task planning resolves the lowercase shim with uppercase PATHEXT." }, { argv = ["vp", "exec", "astro", "--version"], cwd = "packages/app", tty = false }, - { argv = ["vpt", "write-file", "node_modules/.bin/astro.CMD", "@echo wrong workspace shim %*"], snapshot = false }, + { argv = ["node", "later-shim.cjs"], snapshot = false }, { argv = ["vp", "run", "probe"], tty = false, comment = "The earlier package-local lowercase shim wins over a later uppercase shim." }, { argv = ["vp", "exec", "astro", "--version"], cwd = "packages/app", tty = false }, { argv = ["vp", "run", "cached"], cwd = "packages/app", tty = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md index 567477212b..2eb9abfbe5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md @@ -18,7 +18,7 @@ local shim --version local shim --version ``` -## `vpt write-file node_modules/.bin/astro.CMD "@echo wrong workspace shim %*"` +## `node later-shim.cjs` ## `vp run probe` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md index 567477212b..2eb9abfbe5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md @@ -18,7 +18,7 @@ local shim --version local shim --version ``` -## `vpt write-file node_modules/.bin/astro.CMD "@echo wrong workspace shim %*"` +## `node later-shim.cjs` ## `vp run probe` From bdd0e53e60aba3ba6c4ed28bab7d41ec9199938c Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 16:45:04 +0800 Subject: [PATCH 06/11] fix(cli): use upstream which patch for Windows shim resolution --- Cargo.lock | 3 +- Cargo.toml | 5 + crates/vp_command/src/lib.rs | 10 +- crates/vp_command/src/windows_resolve.rs | 227 ----------------------- packages/cli/binding/src/cli/handler.rs | 56 ------ 5 files changed, 8 insertions(+), 293 deletions(-) delete mode 100644 crates/vp_command/src/windows_resolve.rs diff --git a/Cargo.lock b/Cargo.lock index e7e53025df..81be82b1f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9397,8 +9397,7 @@ dependencies = [ [[package]] name = "which" version = "8.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +source = "git+https://github.com/fengmk2/which-rs.git?rev=4f6f1b81b23f2887409bf838f4369690f9c2e336#4f6f1b81b23f2887409bf838f4369690f9c2e336" dependencies = [ "libc", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 6482bd0ece..323ac1d36f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -400,6 +400,11 @@ rolldown_watcher = { path = "./rolldown/crates/rolldown_watcher" } rolldown_workspace = { path = "./rolldown/crates/rolldown_workspace" } string_wizard = { path = "./rolldown/crates/string_wizard", features = ["serde"] } +[patch.crates-io] +# Resolve Windows shims in case-sensitive directories for all which callers. +# Remove this patch after https://github.com/harryfei/which-rs/pull/131 is released. +which = { git = "https://github.com/fengmk2/which-rs.git", rev = "4f6f1b81b23f2887409bf838f4369690f9c2e336" } + # ============================================================================= # Local Development Patches # ============================================================================= diff --git a/crates/vp_command/src/lib.rs b/crates/vp_command/src/lib.rs index e23597349b..cb47683d49 100644 --- a/crates/vp_command/src/lib.rs +++ b/crates/vp_command/src/lib.rs @@ -20,8 +20,6 @@ use vp_error::Error; use vt_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; mod ps1_shim; -#[cfg(any(windows, test))] -mod windows_resolve; /// Result of running a command with fspy tracking. #[derive(Debug)] @@ -32,8 +30,8 @@ pub struct FspyCommandResult { pub path_accesses: HashMap, } -/// Resolve a binary name to a full path, including PATHEXT on Windows. -/// Windows filename matching also works in case-sensitive directories. +/// Resolve a binary name to a full path using the `which` crate. +/// Handles PATHEXT (`.cmd`/`.bat`) resolution natively on Windows. /// /// If `path_env` is `None`, searches the process's current `PATH`. pub fn resolve_bin( @@ -48,10 +46,6 @@ pub fn resolve_bin( current_path = std::env::var_os("PATH").unwrap_or_default(); ¤t_path }; - #[cfg(windows)] - let path = windows_resolve::resolve(bin_name, path_env, cwd.as_ref()) - .ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into()))?; - #[cfg(not(windows))] let path = which::which_in(bin_name, Some(path_env), cwd.as_ref()) .map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?; AbsolutePathBuf::new(path).ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into())) diff --git a/crates/vp_command/src/windows_resolve.rs b/crates/vp_command/src/windows_resolve.rs deleted file mode 100644 index 1f47f6509e..0000000000 --- a/crates/vp_command/src/windows_resolve.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Resolve Windows executable candidates using their on-disk filename casing. -//! -//! `which` 8 checks whether a PATHEXT candidate exists before correcting its -//! casing. In a case-sensitive directory, `.CMD` therefore misses pnpm's `.cmd` -//! shim. Correct each candidate before checking it, preserving PATH/PATHEXT order. - -use std::{ - ffi::{OsStr, OsString}, - path::{Component, Path, PathBuf}, -}; - -use vt_path::AbsolutePath; -use which::sys::{RealSys, Sys}; - -#[cfg(windows)] -pub(super) fn resolve(bin: &str, paths: &OsStr, cwd: &AbsolutePath) -> Option { - resolve_with_extensions(bin, paths, cwd, &RealSys.env_windows_path_ext()) -} - -fn resolve_with_extensions( - bin: &str, - paths: &OsStr, - cwd: &AbsolutePath, - extensions: &[String], -) -> Option { - let bin = Path::new(bin); - if bin.is_absolute() || bin.components().count() > 1 { - return resolve_path(&cwd.as_path().join(bin), extensions); - } - - std::env::split_paths(paths).filter(|dir| !dir.as_os_str().is_empty()).find_map(|dir| { - let mut components = dir.components(); - let dir = if components.next() == Some(Component::Normal(OsStr::new("~"))) { - RealSys.home_dir().map_or_else(|| dir.clone(), |home| home.join(components.as_path())) - } else { - dir - }; - resolve_path(&cwd.as_path().join(dir).join(bin), extensions) - }) -} - -fn resolve_path(path: &Path, extensions: &[String]) -> Option { - let parent = path.parent()?; - let name = path.file_name()?; - // One listing for all extensions. If enumeration is unavailable, retain - // direct lookup so searchable directories do not require list permission. - let names = std::fs::read_dir(parent) - .and_then(|entries| { - entries - .map(|entry| entry.map(|entry| entry.file_name())) - .collect::>>() - }) - .ok(); - let has_extension = path.extension().is_some_and(|ext| { - extensions.iter().any(|candidate| { - candidate.strip_prefix('.').is_some_and(|candidate| ext.eq_ignore_ascii_case(candidate)) - }) - }); - let candidates = std::iter::once(name.to_owned()).chain( - extensions.iter().take(if has_extension { 0 } else { extensions.len() }).map(|ext| { - let mut candidate = name.to_owned(); - candidate.push(ext); - candidate - }), - ); - for candidate in candidates { - // Retain native lookup when ASCII matching finds nothing: an ordinary - // Windows directory may also equate non-ASCII filename casing. - let actual = - names.as_ref().and_then(|names| match_name(names, &candidate)).unwrap_or(&candidate); - let path = parent.join(actual); - // Match which's Windows validation, including executable files without - // an extension and application execution aliases (reparse points). - if std::fs::symlink_metadata(&path).is_ok_and(|meta| meta.is_file() || meta.is_symlink()) - && (path.extension().is_some() || RealSys.is_valid_executable(&path).unwrap_or(false)) - { - return Some(path); - } - } - None -} - -fn match_name<'a>(names: &'a [OsString], candidate: &OsStr) -> Option<&'a OsStr> { - names - .iter() - .find(|name| *name == candidate) - .or_else(|| names.iter().find(|name| name.eq_ignore_ascii_case(candidate))) - .map(OsString::as_os_str) -} - -#[cfg(test)] -mod tests { - use vt_path::AbsolutePathBuf; - - use super::*; - - fn resolve_in( - bin: &str, - dirs: &[PathBuf], - cwd: &AbsolutePath, - ext: &[&str], - ) -> Option { - resolve_with_extensions( - bin, - &std::env::join_paths(dirs).unwrap(), - cwd, - &ext.iter().map(|ext| (*ext).to_owned()).collect::>(), - ) - } - - #[test] - fn resolves_shim_casing_before_trying_later_path_directories() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - let local = cwd.as_path().join("local"); - let global = cwd.as_path().join("global"); - std::fs::create_dir(&local).unwrap(); - std::fs::create_dir(&global).unwrap(); - std::fs::write(local.join("astro.cmd"), "").unwrap(); - std::fs::write(global.join("astro.EXE"), "").unwrap(); - assert_eq!( - resolve_in("astro", &[local.clone(), global], &cwd, &[".EXE", ".CMD"]), - Some(local.join("astro.cmd")) - ); - } - - #[test] - fn preserves_pathext_order_and_matches_mixed_case() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - std::fs::write(temp.path().join("AsTrO.cMd"), "").unwrap(); - std::fs::write(temp.path().join("astro.BAT"), "").unwrap(); - let dirs = [cwd.as_path().to_owned()]; - assert_eq!( - resolve_in("astro", &dirs, &cwd, &[".CMD", ".BAT"]), - Some(cwd.as_path().join("AsTrO.cMd")) - ); - assert_eq!( - resolve_in("astro", &dirs, &cwd, &[".BAT", ".CMD"]), - Some(cwd.as_path().join("astro.BAT")) - ); - } - - #[test] - fn prefers_exact_spelling_when_both_casings_exist() { - let names = [OsString::from("astro.cmd"), OsString::from("astro.CMD")]; - assert_eq!(match_name(&names, OsStr::new("astro.CMD")), Some(OsStr::new("astro.CMD"))); - assert_eq!(match_name(&names, OsStr::new("astro.cmd")), Some(OsStr::new("astro.cmd"))); - } - - #[test] - fn resolves_relative_path_entries_against_task_cwd_and_skips_missing_entries() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - std::fs::create_dir(temp.path().join("bin")).unwrap(); - std::fs::write(temp.path().join("bin/tool.cmd"), "").unwrap(); - assert_eq!( - resolve_in("tool", &["missing".into(), "./bin".into()], &cwd, &[".CMD"]), - Some(cwd.as_path().join("./bin/tool.cmd")) - ); - } - - #[test] - fn resolves_explicit_paths_without_searching_path() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - std::fs::create_dir(temp.path().join("bin")).unwrap(); - let actual = cwd.as_path().join("bin/tool.cmd"); - std::fs::write(&actual, "").unwrap(); - for bin in ["bin/tool", "bin/tool.CMD", actual.to_str().unwrap()] { - assert_eq!(resolve_in(bin, &[], &cwd, &[".CMD"]), Some(actual.clone())); - } - } - - #[test] - fn skips_directories_and_does_not_append_to_a_recognized_extension() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - std::fs::create_dir(temp.path().join("tool.EXE")).unwrap(); - std::fs::write(temp.path().join("tool.cmd"), "").unwrap(); - std::fs::write(temp.path().join("tool.EXE.cmd"), "").unwrap(); - let dirs = [cwd.as_path().to_owned()]; - assert_eq!( - resolve_in("tool", &dirs, &cwd, &[".EXE", ".CMD"]), - Some(cwd.as_path().join("tool.cmd")) - ); - assert_eq!(resolve_in("tool.EXE", &dirs, &cwd, &[".EXE", ".CMD"]), None); - } - - #[test] - fn respects_empty_path_and_pathext() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - std::fs::write(temp.path().join("tool.cmd"), "").unwrap(); - assert_eq!(resolve_in("tool", &[PathBuf::new()], &cwd, &[".CMD"]), None); - let dirs = [cwd.as_path().to_owned()]; - assert_eq!(resolve_in("tool", &dirs, &cwd, &[]), None); - assert_eq!(resolve_in("tool.cmd", &dirs, &cwd, &[]), Some(cwd.as_path().join("tool.cmd"))); - } - - #[test] - fn tries_unrecognized_extensions_verbatim_before_appending_pathext() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - std::fs::write(temp.path().join("tool.js"), "").unwrap(); - std::fs::write(temp.path().join("other.js.cmd"), "").unwrap(); - let dirs = [cwd.as_path().to_owned()]; - assert_eq!( - resolve_in("tool.js", &dirs, &cwd, &[".CMD"]), - Some(cwd.as_path().join("tool.js")) - ); - assert_eq!( - resolve_in("other.js", &dirs, &cwd, &[".CMD"]), - Some(cwd.as_path().join("other.js.cmd")) - ); - } - - #[cfg(windows)] - #[test] - fn retains_native_non_ascii_case_matching() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap(); - std::fs::write(temp.path().join("über.cmd"), "").unwrap(); - let result = resolve_in("ÜBER", &[cwd.as_path().to_owned()], &cwd, &[".CMD"]); - assert!(result.unwrap().is_file()); - } -} diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index 85c06a88d9..a99fc70fa4 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -42,22 +42,6 @@ impl CommandHandler for VitePlusCommandHandler { // `vpr build`, etc. are synthesized in-session rather than spawning a new CLI process. let program = command.program.as_str(); if program != "vp" && program != "vpr" { - // Vite Task resolves verbatim commands itself. Supply the actual - // filename before its which lookup, which otherwise misses .cmd - // shims in case-sensitive Windows directories with .CMD in PATHEXT. - #[cfg(windows)] - if let Ok(path) = vp_command::resolve_bin( - program, - command - .envs - .iter() - .find(|(key, _)| super::is_path_env_key(key)) - .map(|(_, value)| value.as_ref()), - &command.cwd, - ) && let Some(path) = path.as_path().to_str() - { - command.program = path.into(); - } return Ok(HandledCommand::Verbatim); } @@ -128,46 +112,6 @@ impl CommandHandler for VitePlusCommandHandler { } } -#[cfg(all(test, windows))] -mod tests { - use std::{ffi::OsStr, sync::Arc}; - - use rustc_hash::FxHashMap; - use vt_path::AbsolutePathBuf; - - use super::*; - - #[tokio::test] - async fn verbatim_commands_use_the_task_path_with_any_key_casing() { - let temp = tempfile::tempdir().unwrap(); - let cwd: Arc = - AbsolutePathBuf::new(temp.path().canonicalize().unwrap()).unwrap().into(); - let shim = cwd.as_path().join("task-only.cmd"); - std::fs::write(&shim, "@echo off\r\n").unwrap(); - let mut handler = VitePlusCommandHandler::new(SubcommandResolver::new(cwd.clone())); - for key in ["PATH", "Path", "path"] { - let envs = Arc::new(FxHashMap::from_iter([( - Arc::from(OsStr::new(key)), - Arc::from(cwd.as_path().as_os_str()), - )])); - let mut command = ScriptCommand { - program: "task-only.CMD".into(), - args: Arc::from([Str::from("argument with spaces")]), - envs: envs.clone(), - cwd: cwd.clone(), - }; - assert!(matches!( - handler.handle_command(&mut command).await.unwrap(), - HandledCommand::Verbatim - )); - assert_eq!(command.program.as_str(), shim.to_str().unwrap()); - assert_eq!(command.args.as_ref(), [Str::from("argument with spaces")]); - assert!(Arc::ptr_eq(&command.envs, &envs)); - assert!(Arc::ptr_eq(&command.cwd, &cwd)); - } - } -} - /// User config loader that resolves vite.config.ts via JavaScript callback pub(super) struct VitePlusConfigLoader { resolve_fn: ViteConfigResolverFn, From 15e5732771453fe05b4b88236703fa2de9f94a02 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 22:54:37 +0800 Subject: [PATCH 07/11] test: reproduce Windows shim failure without which patch --- Cargo.lock | 3 ++- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81be82b1f4..e7e53025df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9397,7 +9397,8 @@ dependencies = [ [[package]] name = "which" version = "8.0.6" -source = "git+https://github.com/fengmk2/which-rs.git?rev=4f6f1b81b23f2887409bf838f4369690f9c2e336#4f6f1b81b23f2887409bf838f4369690f9c2e336" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" dependencies = [ "libc", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 323ac1d36f..b2f3cdc8fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -400,10 +400,10 @@ rolldown_watcher = { path = "./rolldown/crates/rolldown_watcher" } rolldown_workspace = { path = "./rolldown/crates/rolldown_workspace" } string_wizard = { path = "./rolldown/crates/string_wizard", features = ["serde"] } -[patch.crates-io] +# [patch.crates-io] # Resolve Windows shims in case-sensitive directories for all which callers. # Remove this patch after https://github.com/harryfei/which-rs/pull/131 is released. -which = { git = "https://github.com/fengmk2/which-rs.git", rev = "4f6f1b81b23f2887409bf838f4369690f9c2e336" } +# which = { git = "https://github.com/fengmk2/which-rs.git", rev = "4f6f1b81b23f2887409bf838f4369690f9c2e336" } # ============================================================================= # Local Development Patches From 7c4590ad6a777ded4f760d01d62541d9a0c75465 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 23:04:31 +0800 Subject: [PATCH 08/11] test: limit NTFS temp override to case-sensitive shim snapshots --- .github/workflows/ci.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72cbba77e3..73040b5a2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1104,7 +1104,7 @@ jobs: # Keep fixtures outside runner.temp: setup-node writes an .npmrc there, # which Yarn Classic discovers when walking parent directories. - - name: Create NTFS snapshot temp directory + - name: Create NTFS temp directory for case-sensitive shim snapshots id: snapshot-temp shell: pwsh run: | @@ -1132,13 +1132,20 @@ jobs: export VP_SNAP_PWSH_BIN="$(cygpath -w "$(command -v pwsh.exe)")" # --no-fail-fast: on a snapshot suite every diff is diagnostic # signal; cancelling on the first failure hides the rest. - cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 + test_exit=0 + cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 \ + -E 'not test(windows_case_sensitive_shims)' || test_exit=$? + + # Only this fixture needs NTFS and case-sensitive directory support. + # Rust's GetTempPath2 ignores TEMP/TMP under the SYSTEM account. + # C:\Windows\SystemTemp rejects the case-sensitive flag on these hosts. + # A shard with neither flavor of this fixture has no tests to run. + SystemTemp='${{ steps.snapshot-temp.outputs.directory }}' \ + cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 \ + -E 'test(windows_case_sensitive_shims)' --no-tests=pass || test_exit=$? + exit "$test_exit" env: RUST_BACKTRACE: '1' - # Rust's GetTempPath2 ignores TEMP/TMP under the SYSTEM account. - # Use the runner's NTFS workspace volume: C:\Windows\SystemTemp - # rejects the case-sensitive directory flag on these hosts. - SystemTemp: ${{ steps.snapshot-temp.outputs.directory }} # Keep Windows env parity with the `test` recipe in justfile. __COMPAT_LAYER: RunAsInvoker From bd3932029ea9c9f6a1bf1b178870599076b4e0d8 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 23:21:52 +0800 Subject: [PATCH 09/11] test: run NTFS shim snapshots only on the first Windows shard --- .github/workflows/ci.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73040b5a2a..a20dc402ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1105,6 +1105,7 @@ jobs: # Keep fixtures outside runner.temp: setup-node writes an .npmrc there, # which Yarn Classic discovers when walking parent directories. - name: Create NTFS temp directory for case-sensitive shim snapshots + if: matrix.shard == 1 id: snapshot-temp shell: pwsh run: | @@ -1133,16 +1134,18 @@ jobs: # --no-fail-fast: on a snapshot suite every diff is diagnostic # signal; cancelling on the first failure hides the rest. test_exit=0 + # This fixture requires case-sensitive directory support, which the + # default temp directory on these runners does not provide. cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 \ -E 'not test(windows_case_sensitive_shims)' || test_exit=$? - # Only this fixture needs NTFS and case-sensitive directory support. - # Rust's GetTempPath2 ignores TEMP/TMP under the SYSTEM account. - # C:\Windows\SystemTemp rejects the case-sensitive flag on these hosts. - # A shard with neither flavor of this fixture has no tests to run. - SystemTemp='${{ steps.snapshot-temp.outputs.directory }}' \ - cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 \ - -E 'test(windows_case_sensitive_shims)' --no-tests=pass || test_exit=$? + if [[ '${{ matrix.shard }}' == '1' ]]; then + # Run both flavors on NTFS once. Rust's GetTempPath2 ignores + # TEMP/TMP under the SYSTEM account, so override SystemTemp. + SystemTemp='${{ steps.snapshot-temp.outputs.directory }}' \ + cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast \ + -E 'test(windows_case_sensitive_shims)' || test_exit=$? + fi exit "$test_exit" env: RUST_BACKTRACE: '1' From 32832fa68d97aa2c2c4a8c704c93258c06d5e448 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 23:40:18 +0800 Subject: [PATCH 10/11] fix(cli): add Windows PATHEXT casing variants before command lookup --- Cargo.lock | 1 + Cargo.toml | 5 -- .../packages/app/package.json | 3 +- .../windows_case_sensitive_shims/setup.cjs | 5 +- .../snapshots.toml | 2 + crates/vp_global_cli/src/main.rs | 1 + crates/vp_shared/Cargo.toml | 1 + crates/vp_shared/src/lib.rs | 4 +- crates/vp_shared/src/path_env.rs | 56 ++++++++++++++++++- packages/cli/binding/src/lib.rs | 1 + 10 files changed, 70 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7e53025df..b7155d0f89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8923,6 +8923,7 @@ name = "vp_shared" version = "0.0.0" dependencies = [ "console", + "cow-utils", "directories", "nix 0.30.1", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index b2f3cdc8fa..6482bd0ece 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -400,11 +400,6 @@ rolldown_watcher = { path = "./rolldown/crates/rolldown_watcher" } rolldown_workspace = { path = "./rolldown/crates/rolldown_workspace" } string_wizard = { path = "./rolldown/crates/string_wizard", features = ["serde"] } -# [patch.crates-io] -# Resolve Windows shims in case-sensitive directories for all which callers. -# Remove this patch after https://github.com/harryfei/which-rs/pull/131 is released. -# which = { git = "https://github.com/fengmk2/which-rs.git", rev = "4f6f1b81b23f2887409bf838f4369690f9c2e336" } - # ============================================================================= # Local Development Patches # ============================================================================= diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json index 92eed585a4..5b3bcd0b65 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json @@ -2,6 +2,7 @@ "name": "@fixture/app", "private": true, "scripts": { - "probe": "astro --version" + "probe": "astro --version", + "priority": "node-priority -e \"process.exit(0)\"" } } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs index 972f6b4710..f9f8a36665 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs @@ -9,5 +9,8 @@ fs.mkdirSync(bin, { recursive: true }); // on the checkout's NTFS flags or the casing chosen by a package manager. execFileSync('fsutil.exe', ['file', 'setCaseSensitiveInfo', bin, 'enable'], { stdio: 'inherit' }); fs.writeFileSync(path.join(bin, 'astro.cmd'), '@echo off\r\nnode "%~dp0/../../print.cjs" %*\r\n'); +// A lowercase executable must win over an uppercase command shim in the same directory. +fs.copyFileSync(process.execPath, path.join(bin, 'node-priority.exe')); +fs.writeFileSync(path.join(bin, 'node-priority.CMD'), '@echo wrong cmd shim\r\n@exit /b 1\r\n'); assert.equal(fs.existsSync(path.join(bin, 'astro.CMD')), false); -assert.equal(process.env.PATHEXT, '.COM;.EXE;.BAT;.CMD'); +assert.ok(process.env.PATHEXT.split(';').includes('.CMD')); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml index bb7c073b06..64b85b8895 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml @@ -5,6 +5,8 @@ skip-platforms = ["linux", "macos"] env = { PATHEXT = ".COM;.EXE;.BAT;.CMD" } steps = [ { argv = ["node", "setup.cjs"], snapshot = false }, + { argv = ["vp", "run", "priority"], cwd = "packages/app", tty = false, snapshot = false }, + { argv = ["vp", "exec", "node-priority", "-e", "process.exit(0)"], cwd = "packages/app", tty = false, snapshot = false }, { argv = ["vp", "run", "probe"], tty = false, comment = "Nested task planning resolves the lowercase shim with uppercase PATHEXT." }, { argv = ["vp", "exec", "astro", "--version"], cwd = "packages/app", tty = false }, { argv = ["node", "later-shim.cjs"], snapshot = false }, diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 5f4cb4d341..ae6bbaabdf 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -380,6 +380,7 @@ async fn main() -> ExitCode { } vp_shared::ensure_blocking_stdio(); + vp_shared::ensure_windows_pathext(); // Initialize tracing vp_shared::init_tracing(); diff --git a/crates/vp_shared/Cargo.toml b/crates/vp_shared/Cargo.toml index ecc58afb7d..62d4158f57 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -18,6 +18,7 @@ test-utils = ["dep:temp-env", "dep:tempfile"] directories = { workspace = true } nix = { workspace = true, features = ["fs", "poll", "term"] } console = { workspace = true } +cow-utils = { workspace = true } serde = { workspace = true } # use `preserve_order` feature to preserve the order of the fields in `package.json` serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index ead1abf014..1ec2a26c13 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -39,7 +39,9 @@ pub use json_edit::{JsonStyle, edit_json_object, insert_after}; pub use package_json::{ DevEngineDependency, DevEngineField, DevEngines, Engines, OnFail, PackageJson, dev_engine_entry, }; -pub use path_env::{PrependOptions, ToolPathEnv, prepend_tools_to_path_env}; +pub use path_env::{ + PrependOptions, ToolPathEnv, ensure_windows_pathext, prepend_tools_to_path_env, +}; pub use process::exit_code_from_status; pub use stdio::ensure_blocking_stdio; pub use tls::ensure_tls_provider; diff --git a/crates/vp_shared/src/path_env.rs b/crates/vp_shared/src/path_env.rs index 33ddba5cf8..b8c03c0580 100644 --- a/crates/vp_shared/src/path_env.rs +++ b/crates/vp_shared/src/path_env.rs @@ -1,4 +1,4 @@ -//! PATH environment variable manipulation utilities. +//! PATH and PATHEXT environment variable manipulation utilities. //! //! This module provides functions for prepending directories to the PATH //! environment variable with various deduplication strategies. @@ -9,6 +9,44 @@ use vt_path::AbsolutePath; use crate::env_vars; +/// Add uppercase and lowercase variants of each configured Windows extension. +/// Call during CLI initialization, before `which` caches PATHEXT. This also +/// supplies the same extension order to Vite Task, fspy, and child processes. +pub fn ensure_windows_pathext() { + #[cfg(windows)] + if let Ok(pathext) = env::var("PATHEXT") { + let expanded = expand_pathext_case_variants(&pathext); + if expanded != pathext { + // SAFETY: Environment mutation is thread-safe on Windows. This is + // also called when the NAPI binding loads in a Node.js process. + unsafe { env::set_var("PATHEXT", expanded) }; + } + } +} + +#[cfg(any(windows, test))] +fn expand_pathext_case_variants(pathext: &str) -> String { + use std::borrow::Cow; + + use cow_utils::CowUtils; + + let mut extensions = Vec::new(); + for extension in pathext.split(';') { + // Keep each variant next to its original extension so .EXE and .exe + // both precede .CMD and .cmd in the standard Windows search order. + for variant in [ + Cow::Borrowed(extension), + extension.cow_to_ascii_uppercase(), + extension.cow_to_ascii_lowercase(), + ] { + if !extensions.contains(&variant) { + extensions.push(variant); + } + } + } + extensions.join(";") +} + /// PATH and the tools whose real binary directories Vite+ has injected into it. /// Keep both values together when preparing a child process environment. #[derive(Debug, Clone)] @@ -99,6 +137,22 @@ mod tests { use super::*; + #[test] + fn pathext_keeps_exe_variants_before_cmd_variants() { + assert_eq!( + expand_pathext_case_variants(".COM;.EXE;.BAT;.CMD"), + ".COM;.com;.EXE;.exe;.BAT;.bat;.CMD;.cmd" + ); + } + + #[test] + fn pathext_preserves_custom_order_and_is_idempotent() { + let expanded = expand_pathext_case_variants(".cmd;.exe;.CMD;.PY"); + assert_eq!(expanded, ".cmd;.CMD;.exe;.EXE;.PY;.py"); + assert_eq!(expand_pathext_case_variants(&expanded), expanded); + assert_eq!(expand_pathext_case_variants(""), ""); + } + #[test] fn injected_tools_accumulate_without_changing_other_environments() { let original = ToolPathEnv::new(OsString::new(), "node,npm,npx,node"); diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index e195bfb55b..cb5b1368bb 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -45,6 +45,7 @@ use crate::cli::{ #[allow(clippy::disallowed_macros)] pub fn init() { vp_shared::ensure_blocking_stdio(); + vp_shared::ensure_windows_pathext(); crate::cli::init_tracing(); // Install a Vite+ panic hook so panics are correctly attributed to Vite+. From 93861857bcf8de6552cf7b1a2c1910f6f412e5c6 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 23:55:50 +0800 Subject: [PATCH 11/11] test: accept Windows executable priority snapshot headings --- .../snapshots/windows_case_sensitive_shims.global.md | 6 ++++++ .../snapshots/windows_case_sensitive_shims.local.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md index 2eb9abfbe5..6a63dd9f6a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md @@ -3,6 +3,12 @@ ## `node setup.cjs` +## `cd packages/app && vp run priority` + + +## `cd packages/app && vp exec node-priority -e process.exit(0)` + + ## `vp run probe` Nested task planning resolves the lowercase shim with uppercase PATHEXT. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md index 2eb9abfbe5..6a63dd9f6a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md @@ -3,6 +3,12 @@ ## `node setup.cjs` +## `cd packages/app && vp run priority` + + +## `cd packages/app && vp exec node-priority -e process.exit(0)` + + ## `vp run probe` Nested task planning resolves the lowercase shim with uppercase PATHEXT.