Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e532003
docs(rfc): document split directory layout via VpDirs
forehalo Aug 7, 2026
f4d0455
feat(shared): introduce VpDirs with strategy-gated path resolution
forehalo Aug 7, 2026
eb2aba1
refactor(cli): route on-disk paths through VpDirs
forehalo Aug 11, 2026
0c5ca17
feat(install): default installers to the split XDG layout
forehalo Aug 13, 2026
67ec04a
fix(ci): keep bootstrap on the grandfathered ~/.vite-plus root
forehalo Aug 13, 2026
67c2856
fix(js_runtime): isolate find_cached_version from VP_NODE_DIST_MIRROR
forehalo Aug 13, 2026
55cd33a
fix: honor split-layout review comments
forehalo Aug 14, 2026
a520d23
fix: locate Windows trampolines via per-exe .shim sidecars
forehalo Aug 16, 2026
75d288b
fix(ci): expect absolute Unix vp shim after install.sh
forehalo Aug 16, 2026
45cd917
fix: address remaining split-layout review comments
forehalo Aug 16, 2026
371d196
fix(install): install pre-split releases into the monolithic root
fengmk2 Aug 17, 2026
7c056cb
docs(rfc): drop the version-gate rejection rationale
fengmk2 Aug 17, 2026
286c20a
test(shared): isolate split-layout test from developer env
fengmk2 Aug 17, 2026
22f4df1
fix(dirs): grandfather ~/.vite-plus only when it holds an install
fengmk2 Aug 17, 2026
519cf33
refactor(install): dedupe layout mapping and adopt probed dirs in vp-…
fengmk2 Aug 17, 2026
39cdda5
docs(install): tighten pre-split prose and unify the notice wording
fengmk2 Aug 17, 2026
628b6b2
fix(tools): pin VP_HOME for the CI bootstrap
fengmk2 Aug 17, 2026
abba0f2
fix(upgrade): reject pre-split targets on split installs
fengmk2 Aug 17, 2026
b86a874
docs: point remaining path references at the resolved category direct…
fengmk2 Aug 18, 2026
16e7178
fix(install): preserve unrelated Node executables
fengmk2 Aug 18, 2026
e87c65b
fix(install): preserve foreign Windows shims
fengmk2 Aug 18, 2026
794b5eb
docs: explain split-layout upgrade path
fengmk2 Aug 18, 2026
92b341e
fix(upgrade): point split installs to latest
fengmk2 Aug 18, 2026
cdfac66
fix(upgrade): clarify latest-version guidance
fengmk2 Aug 18, 2026
f847503
refactor(installer): resolve dirs through vp payload
fengmk2 Aug 18, 2026
62034fe
fix(implode): normalize deletion roots
fengmk2 Aug 18, 2026
663e81e
docs(rfc): clarify directory ownership semantics
fengmk2 Aug 18, 2026
be5ebea
fix(installer): escape shell config paths
fengmk2 Aug 18, 2026
ec89e25
fix(installer): separate shell and resolution homes
fengmk2 Aug 18, 2026
f1e2688
feat: use Vite+-owned default bin directory
fengmk2 Aug 18, 2026
59970d3
docs(rfc): clarify XDG bin semantics
fengmk2 Aug 18, 2026
c8ad597
test(installer): assert platform-specific bin layout
fengmk2 Aug 18, 2026
0e3c4ee
fix(installer): preserve setup-vp legacy path
fengmk2 Aug 18, 2026
015af1d
fix(env): escape directory paths in shell files
fengmk2 Aug 18, 2026
940a1ea
docs: clear directory overrides during layout migration
fengmk2 Aug 19, 2026
11f389d
chore: simplify directory layout language
fengmk2 Aug 19, 2026
6e39547
fix(trampoline): preserve split directory layout
fengmk2 Aug 19, 2026
f17ae1c
fix(implode): defer locked Windows shim removal
fengmk2 Aug 19, 2026
d9cb5d8
fix(env): write PowerShell setup as UTF-8 BOM
fengmk2 Aug 19, 2026
b1f154c
test(env): check the first resolved Windows shim
fengmk2 Aug 19, 2026
0d6318a
fix(implode): complete deferred Windows root cleanup
fengmk2 Aug 19, 2026
2922dfc
fix(dirs): require complete directory overrides
fengmk2 Aug 19, 2026
bbf954d
fix(ci): pass directory layout checks
fengmk2 Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
642 changes: 640 additions & 2 deletions .github/workflows/test-standalone-install.yml

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ vite-plus/
└── crates/vp_trampoline/ # Windows shim trampoline
```

Vite+ resolves all on-disk paths through `vp_shared::VpDirs`.
`crates/vp_shared/src/dirs.rs` defines the roots, and `dirs/resolution.rs`
defines the resolution chain. The chain uses split XDG or platform roots by
default. It uses one root when `VP_HOME` is set or an existing `~/.vite-plus`
install is present. Call sites must not construct category paths or read
`VP_HOME` or `XDG_*` directly.

`packages/test` is no longer tracked. The public test API is `vite-plus/test*`, generated by `packages/cli/build.ts` as shims over upstream `vitest` and `@vitest/browser*` exports.

## Where to Start
Expand Down Expand Up @@ -108,7 +115,7 @@ pnpm bootstrap-cli # Build packages, compile vp/NAPI, and install the global CL
vp --version
```

Use `pnpm bootstrap-cli` when you need to validate the installed global CLI at `~/.vite-plus`.
Use `pnpm bootstrap-cli` when you need to validate the installed global CLI end-to-end.

### Routine validation

Expand Down
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ sugar_path = { version = "3", features = ["cached_current_dir"] }
supports-color = "3"
syn = { version = "2", default-features = false }
tar = "0.4.43"
temp-env = "0.3.6"
tempfile = "3.14.0"
terminal_size = "0.4.2"
test-log = { version = "0.2.18", features = ["trace"] }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
source env.nu

let expected_bin = ($env.EXPECTED_VP_BIN_DIR | path expand --no-symlink)
let env_names = ($env | columns)
for name in [VP_BIN_DIR VP_DATA_DIR VP_CACHE_DIR] {
if $name in $env_names {
error make {
msg: $"env.nu must not export ($name)"
}
}
}

let actual_bin = ($env.PATH | first)
if $actual_bin != $expected_bin {
error make {
msg: $"PATH mismatch: expected first entry ($expected_bin), got ($actual_bin)"
}
}

let bin_count = ($env.PATH | where { $in == $expected_bin } | length)
if $bin_count != 1 {
error make {
msg: $"PATH contains the Vite+ bin directory ($bin_count) times"
}
}

print "Nushell metacharacter path checks passed"
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,14 @@ steps = [
{ argv = ["vpt", "cp", "assert.nu", 'vp "home\with spaces"/assert.nu'], snapshot = false },
{ argv = ["nu", "assert.nu"], cwd = 'vp "home\with spaces"', comment = "loads the generated env.nu and verifies the Nushell wrapper", envs = [["EXPECTED_VP_HOME", "${workspace}"], ["PATH", "${workspace}/bin:${workspace}/bin:${PATH}"]] },
]

[[case]]
name = "command_env_nushell_metachar_dirs"
vp = "global"
skip-platforms = ["windows"]
requires = ["nu"]
steps = [
{ argv = ["vp", "env", "setup", "--refresh"], envs = [["HOME", "${workspace}/shell-home"], ["USERPROFILE", "${workspace}/shell-home"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/bin-$USER-\"quote\"-'single'-`tick`-\\slash"], ["VP_DATA_DIR", "${workspace}/data-$USER-\"quote\"-'single'-`tick`-\\slash"], ["VP_CACHE_DIR", "${workspace}/cache-$USER-\"quote\"-'single'-`tick`-\\slash"], ["XDG_CONFIG_HOME", "${workspace}/config-$USER-\"quote\"-'single'-`tick`-\\slash"]], snapshot = false },
{ argv = ["vpt", "cp", "assert_dirs.nu", "config-$USER-\"quote\"-'single'-`tick`-\\slash/vite-plus/assert_dirs.nu"], snapshot = false },
{ argv = ["nu", "config-$USER-\"quote\"-'single'-`tick`-\\slash/vite-plus/assert_dirs.nu"], comment = "Nushell loads env.nu, preserves a PATH with shell metacharacters, and omits internal directory variables", envs = [["HOME", "${workspace}/shell-home"], ["USERPROFILE", "${workspace}/shell-home"], ["VP_HOME", ""], ["EXPECTED_VP_BIN_DIR", "${workspace}/bin-$USER-\"quote\"-'single'-`tick`-\\slash"], ["PATH", "${workspace}/bin-$USER-\"quote\"-'single'-`tick`-\\slash:${workspace}/bin-$USER-\"quote\"-'single'-`tick`-\\slash:${PATH}"]] },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# command_env_nushell_metachar_dirs

## `HOME=${workspace}/shell-home USERPROFILE=${workspace}/shell-home VP_HOME= VP_BIN_DIR=${workspace}/bin-$USER-"quote"-'single'-`tick`-\slash VP_DATA_DIR=${workspace}/data-$USER-"quote"-'single'-`tick`-\slash VP_CACHE_DIR=${workspace}/cache-$USER-"quote"-'single'-`tick`-\slash XDG_CONFIG_HOME=${workspace}/config-$USER-"quote"-'single'-`tick`-\slash vp env setup --refresh`


## `vpt cp assert_dirs.nu 'config-$USER-"quote"-'\''single'\''-`tick`-\slash/vite-plus/assert_dirs.nu'`


## `HOME=${workspace}/shell-home USERPROFILE=${workspace}/shell-home VP_HOME= EXPECTED_VP_BIN_DIR=${workspace}/bin-$USER-"quote"-'single'-`tick`-\slash PATH=${workspace}/bin-$USER-"quote"-'single'-`tick`-\slash:${workspace}/bin-$USER-"quote"-'single'-`tick`-\slash:${PATH} nu 'config-$USER-"quote"-'\''single'\''-`tick`-\slash/vite-plus/assert_dirs.nu'`

Nushell loads env.nu, preserves a PATH with shell metacharacters, and omits internal directory variables

```
Nushell metacharacter path checks passed
```
96 changes: 41 additions & 55 deletions crates/vp_command/src/ps1_shim.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,27 @@
//! Windows-specific: when a vp-managed package-manager `.cmd` shim has a
//! sibling `.ps1`, rewrite the spawn to go through
//! `powershell.exe -File <sibling.ps1>`.
//! On Windows, route a managed package-manager `.cmd` shim through
//! `powershell.exe -File <sibling.ps1>` when the sibling `.ps1` file exists.
//!
//! Running a `.cmd` from any shell makes `cmd.exe` prompt "Terminate batch
//! job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Routing through
//! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly.
//! When a shell runs a `.cmd` file, Ctrl+C makes `cmd.exe` show a termination
//! prompt. This prompt can damage the terminal state. PowerShell does not show
//! the prompt and passes Ctrl+C to the child process.
//!
//! The rewrite is scoped to two patterns:
//! - Inside `$VP_HOME` (`~/.vite-plus` by default) — vp's managed shims:
//! - `$VP_HOME/js_runtime/node/<ver>/{npm,npx}.cmd`,
//! - `$VP_HOME/package_manager/<pm>/<ver>/<pm>/bin/<pm>.cmd`.
//! - Any `<...>/node_modules/.bin/*.cmd` — the canonical layout for
//! npm/pnpm/yarn-emitted shims (cmd-shim writes both `.cmd` and `.ps1`
//! so the wrappers stay equivalent).
//! - Managed shims in the Vite+ data root (`<DATA>`):
//! - `<DATA>/js_runtime/node/<ver>/{npm,npx}.cmd`,
//! - `<DATA>/package_manager/<pm>/<ver>/<pm>/bin/<pm>.cmd`.
//! - Each `<...>/node_modules/.bin/*.cmd` shim. npm, pnpm, and Yarn use this
//! standard layout. `cmd-shim` writes equivalent `.cmd` and `.ps1` files.
//!
//! Anything outside both patterns — system tools, third-party CLIs whose
//! `.cmd` and `.ps1` wrappers may diverge — keeps its existing `.cmd`
//! path (Ctrl+C corruption included), so we don't silently change
//! execution semantics for unrelated commands or bypass execution
//! policies on locked-down hosts.
//! Keep the `.cmd` path for files outside these patterns. This includes system
//! tools and third-party CLIs with different `.cmd` and `.ps1` behavior. This
//! rule keeps the execution behavior of unrelated commands. It also obeys host
//! execution policies.
//!
//! The rewrite is also skipped when stdin is not a terminal. The
//! `pnpm`/`npm`/`yarn` `.ps1` wrappers introspect stdin (e.g.
//! `$MyInvocation.ExpectingInput`) and hang when stdin is piped or
//! null; in that environment there is no terminal to corrupt with the
//! Ctrl+C prompt anyway, so falling back to `.cmd` is strictly safer.
//! Do not rewrite when standard input is not a terminal. The pnpm, npm, and
//! Yarn `.ps1` wrappers inspect standard input. For example, they use
//! `$MyInvocation.ExpectingInput`. They can stop responding when input is a
//! pipe or null. In this case, there is no terminal that the Ctrl+C prompt can
//! damage. Use the `.cmd` file.
//!
//! See <https://github.com/voidzero-dev/vite-plus/issues/1489>
//! and <https://github.com/voidzero-dev/vite-plus/issues/1176>.
Expand All @@ -36,45 +33,35 @@ use vt_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, is_stdin_terminal, powe

/// Rewrite a vp-managed `.cmd` invocation to go through `PowerShell`.
///
/// Returns `Some((powershell_host, prefix_args))` when the rewrite applies.
/// `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass",
/// "-File", <abs ps1 path>]`; callers prepend it to the user args and spawn
/// `powershell_host`.
/// Return `Some((powershell_host, prefix_args))` when the rewrite applies.
/// `prefix_args` contains `-NoProfile`, `-NoLogo`, `-ExecutionPolicy`,
/// `Bypass`, `-File`, and the absolute `.ps1` path. Add these arguments before
/// the user arguments. Then start `powershell_host`.
///
/// Returns `None` when:
/// - not on Windows,
/// - no `PowerShell` host (`pwsh.exe` or `powershell.exe`) is on PATH,
/// - stdin is not a terminal (the `.ps1` wrappers hang on piped/null
/// stdin and the Ctrl+C concern doesn't apply without a TTY),
/// - the resolved path is outside `$VP_HOME` (or `$VP_HOME` is
/// unresolvable) AND not under any `node_modules/.bin/`,
/// - standard input is not a terminal,
/// - the resolved path is not in the Vite+ data root or a
/// `node_modules/.bin/` directory,
/// - the resolved path is not a `.cmd` (case-insensitive),
/// - the `.cmd` has no sibling `.ps1`.
#[must_use]
pub fn rewrite_cmd_to_powershell(
resolved: &AbsolutePath,
) -> Option<(AbsolutePathBuf, Vec<OsString>)> {
// `build_command` always inherits stdin into spawned children, so a TTY on
// our stdin means a TTY in the child too. `is_stdin_terminal` is shared with
// `vt_plan::ps1_shim` via the `vt_powershell` crate.
// `build_command` gives its standard input to child processes. Thus, a TTY
// here is also a TTY in the child. The `vt_powershell` crate shares
// `is_stdin_terminal` with `vt_plan::ps1_shim`.
let host = powershell_host()?;
rewrite_in_scope(resolved, vp_home().map(AsRef::as_ref), host, is_stdin_terminal())
// Vite+ managed shims are under the data root (`<DATA>/js_runtime/…`,
// `<DATA>/package_manager/…`).
let config = vp_shared::EnvConfig::get();
rewrite_in_scope(resolved, Some(config.dirs.data.as_absolute_path()), host, is_stdin_terminal())
}

/// Cached `$VP_HOME` (`~/.vite-plus` by default; overridable via env var).
/// Returns `None` if `vp_shared::get_vp_home()` failed; the rewrite still
/// applies to `node_modules/.bin/*.cmd` paths in that case (the two scopes
/// are independent).
fn vp_home() -> Option<&'static AbsolutePathBuf> {
use std::sync::LazyLock;

static VP_HOME: LazyLock<Option<AbsolutePathBuf>> =
LazyLock::new(|| vp_shared::get_vp_home().ok());
VP_HOME.as_ref()
}

/// Pure rewrite logic. Factored out so tests can drive it on any platform
/// without depending on a real `powershell.exe` or a real `$VP_HOME`.
/// Apply the rewrite without external state. Tests can call this function on
/// each platform without a real `powershell.exe` or Vite+ data root.
fn rewrite_in_scope(
resolved: &AbsolutePath,
vp_home: Option<&AbsolutePath>,
Expand Down Expand Up @@ -108,9 +95,9 @@ fn is_in_managed_scope(resolved: &AbsolutePath, vp_home: Option<&AbsolutePath>)
in_vp_home || is_in_node_modules_bin(resolved)
}

/// `true` when `resolved` is `<...>/node_modules/.bin/<file>` (matched
/// case-insensitively on the `.bin`/`node_modules` components Windows
/// is case-insensitive, and pnpm's hoisted layouts can vary in casing).
/// Return `true` when `resolved` is `<...>/node_modules/.bin/<file>`. Compare
/// the `.bin` and `node_modules` components without case sensitivity. Windows
/// is not case-sensitive, and pnpm hoisted layouts can use different case.
fn is_in_node_modules_bin(resolved: &AbsolutePath) -> bool {
let mut parents = resolved.as_path().components().rev();
parents.next(); // shim filename
Expand Down Expand Up @@ -211,10 +198,9 @@ mod tests {
);
}

/// `vp_home` may be unresolvable in unusual environments (CI containers
/// missing $HOME, sandboxed shells); when that happens the
/// `node_modules/.bin` scope must still rewrite, since it is
/// architecturally independent from the `$VP_HOME` scope.
/// When no vp data root participates in the scope check (`None` here),
/// the `node_modules/.bin` scope must still rewrite, since it is
/// architecturally independent from the data-root scope.
#[test]
fn rewrites_cmd_in_node_modules_bin_when_vp_home_unresolved() {
let dir = tempdir().unwrap();
Expand Down
3 changes: 3 additions & 0 deletions crates/vp_global_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
owo-colors = { workspace = true }
oxc_resolver = { workspace = true }
rustc-hash = { workspace = true }
crossterm = { workspace = true }
indexmap = { workspace = true }
indicatif = { workspace = true }
Expand All @@ -45,7 +46,9 @@ uuid = { workspace = true, features = ["v4"] }

[dev-dependencies]
serial_test = { workspace = true }
temp-env = { workspace = true }
tempfile = { workspace = true }
vp_shared = { workspace = true, features = ["test-utils"] }

[lints]
workspace = true
Loading
Loading